@stage-labs/metro 0.1.0-beta.45 → 0.1.0-beta.47
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 +7 -7
- package/dist/plugin.js +1 -1
- package/dist/prompt.js +1 -1
- package/dist/runtime.js +1 -1
- package/dist/serve.js +35 -5
- 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 +13 -26
- 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 +11 -10
- 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 +3 -39
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-owner.ts +14 -0
- 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 +20 -34
- 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,86 +1,7 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { errMsg, log } from './log.js';
|
|
3
1
|
import { Line } from '../stations/lines.js';
|
|
4
2
|
|
|
5
|
-
const
|
|
6
|
-
|
|
3
|
+
export const userSelf = (): Line =>
|
|
4
|
+
(process.env.METRO_FROM ?? 'metro://user') as Line;
|
|
7
5
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return () => {
|
|
11
|
-
if (cache && Date.now() - cache.at < TTL_MS) return cache.id;
|
|
12
|
-
const id = loader();
|
|
13
|
-
cache = { id, at: Date.now() };
|
|
14
|
-
return id;
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const claudeAccountId = memo(() => {
|
|
19
|
-
let raw: string;
|
|
20
|
-
try {
|
|
21
|
-
raw = execFileSync('claude', ['auth', 'status', '--json'], {
|
|
22
|
-
encoding: 'utf8',
|
|
23
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
24
|
-
});
|
|
25
|
-
} catch (e) {
|
|
26
|
-
throw new Error(
|
|
27
|
-
`metro: failed to run 'claude auth status --json' — is Claude Code installed? (${errMsg(e)})`,
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
let p: { loggedIn?: boolean; orgId?: string };
|
|
31
|
-
try {
|
|
32
|
-
p = JSON.parse(raw) as { loggedIn?: boolean; orgId?: string };
|
|
33
|
-
} catch {
|
|
34
|
-
throw new Error(
|
|
35
|
-
`metro: 'claude auth status --json' returned non-JSON: ${raw.slice(0, 200)}`,
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
if (!p.loggedIn || !p.orgId)
|
|
39
|
-
throw new Error(
|
|
40
|
-
"metro: Claude Code is not logged in — run 'claude auth login'",
|
|
41
|
-
);
|
|
42
|
-
return p.orgId;
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
export const claudeUserId = (): string =>
|
|
46
|
-
process.env.METRO_USER_ID ?? claudeAccountId();
|
|
47
|
-
|
|
48
|
-
export const claudeSessionId = (): string | null =>
|
|
49
|
-
process.env.METRO_USER_SESSION_ID ??
|
|
50
|
-
process.env.CLAUDE_CODE_SESSION_ID ??
|
|
51
|
-
null;
|
|
52
|
-
|
|
53
|
-
export function userSelf(): Line {
|
|
54
|
-
const explicit = process.env.METRO_FROM;
|
|
55
|
-
if (explicit) return explicit as Line;
|
|
56
|
-
if (!process.env.CLAUDECODE) return 'metro://user' as Line;
|
|
57
|
-
try {
|
|
58
|
-
return Line.user('claude', claudeUserId());
|
|
59
|
-
} catch (e) {
|
|
60
|
-
log.warn(
|
|
61
|
-
{ reason: errMsg(e) },
|
|
62
|
-
'could not resolve a Claude account id; relaying as metro://user',
|
|
63
|
-
);
|
|
64
|
-
return 'metro://user' as Line;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function daemonSelf(): Line {
|
|
69
|
-
const explicit = process.env.METRO_FROM ?? process.env.METRO_SELF_URI;
|
|
70
|
-
return (explicit ?? 'metro://user') as Line;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function selfLine(): Line | null {
|
|
74
|
-
if (!process.env.CLAUDECODE) return null;
|
|
75
|
-
const s = claudeSessionId();
|
|
76
|
-
if (!s) return null;
|
|
77
|
-
try {
|
|
78
|
-
return Line.claude(claudeUserId(), s);
|
|
79
|
-
} catch (e) {
|
|
80
|
-
log.warn(
|
|
81
|
-
{ reason: errMsg(e) },
|
|
82
|
-
'could not resolve a Claude account id; relaying with no self line',
|
|
83
|
-
);
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
6
|
+
export const daemonSelf = (): Line =>
|
|
7
|
+
(process.env.METRO_FROM ?? process.env.METRO_SELF_URI ?? 'metro://user') as Line;
|
|
@@ -14,7 +14,7 @@ export interface LocalCliDeps {
|
|
|
14
14
|
connectorSummaries: (agentId: string) => Promise<ConnectorSummary[]>;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
function keyOf(req: IncomingMessage): string {
|
|
18
18
|
const header = req.headers.authorization ?? '';
|
|
19
19
|
if (header.toLowerCase().startsWith('bearer ')) return header.slice(7).trim();
|
|
20
20
|
const query = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
|
|
@@ -29,7 +29,7 @@ export function keyIdentity(req: IncomingMessage): AgentIdentity | null {
|
|
|
29
29
|
|
|
30
30
|
async function answer(path: string, key: string, agentId: string, deps: LocalCliDeps): Promise<unknown> {
|
|
31
31
|
const agent = deps.agentName(agentId) ?? '';
|
|
32
|
-
if (path === `${PREFIX}/session`) return {
|
|
32
|
+
if (path === `${PREFIX}/session`) return { subject: 'this machine', agent };
|
|
33
33
|
if (path === `${PREFIX}/connectors`) return { agent, connectors: await deps.connectorSummaries(agentId) };
|
|
34
34
|
return { json: relayServersJson(await deps.connectorEntries(agentId), publicBaseOrDefault(), key), agent };
|
|
35
35
|
}
|
|
@@ -5,7 +5,6 @@ import { AttachSessions } from './attach-session.js';
|
|
|
5
5
|
import type { AgentApiDeps } from './agent-api.js';
|
|
6
6
|
import { ATTACHABLE, type AccountApiDeps } from './account-api.js';
|
|
7
7
|
import type { AgentConnectorApiDeps } from './agent-connector-api.js';
|
|
8
|
-
import type { ProjectApiDeps } from './project-api.js';
|
|
9
8
|
import type { SessionApis } from './session-apis.js';
|
|
10
9
|
import type { ModeInfo } from './mode-api.js';
|
|
11
10
|
import type { ImportApiDeps } from './import-api.js';
|
|
@@ -33,7 +32,7 @@ import {
|
|
|
33
32
|
} from '../db/local-connectors.js';
|
|
34
33
|
import {
|
|
35
34
|
assertLocalOwner,
|
|
36
|
-
|
|
35
|
+
ownerSignIn,
|
|
37
36
|
connectorIdsOfLocalAgent,
|
|
38
37
|
LOCAL_PROJECT_ID,
|
|
39
38
|
localAttachAccount,
|
|
@@ -46,9 +45,7 @@ import {
|
|
|
46
45
|
localOwner,
|
|
47
46
|
localResetAgentKey,
|
|
48
47
|
} from '../db/file-admin.js';
|
|
49
|
-
import { normalizeAddress } from '../db/users.js';
|
|
50
48
|
import { listAgentFiles, readAgentFile } from '../db/file-source.js';
|
|
51
|
-
import type { Project } from '../db/projects.js';
|
|
52
49
|
import type { StationName } from '../db/schema.js';
|
|
53
50
|
|
|
54
51
|
export interface LocalModeDeps {
|
|
@@ -66,16 +63,6 @@ const notHere = (what: string): Promise<never> =>
|
|
|
66
63
|
new ApiError(`${what} are managed on metro.box, not on a local daemon`, 400),
|
|
67
64
|
);
|
|
68
65
|
|
|
69
|
-
function localProject(): Project {
|
|
70
|
-
return {
|
|
71
|
-
id: LOCAL_PROJECT_ID,
|
|
72
|
-
name: hostname(),
|
|
73
|
-
isDefault: true,
|
|
74
|
-
owner: true,
|
|
75
|
-
role: 'admin',
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
66
|
function attachSessions(deps: LocalModeDeps): AttachSessions {
|
|
80
67
|
return new AttachSessions({
|
|
81
68
|
authorize: async (owner) => {
|
|
@@ -124,28 +111,6 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
|
|
|
124
111
|
};
|
|
125
112
|
}
|
|
126
113
|
|
|
127
|
-
const projectApi: ProjectApiDeps = {
|
|
128
|
-
listProjects: (subject) => {
|
|
129
|
-
const owner = localOwner();
|
|
130
|
-
const mine = owner !== null && owner === normalizeAddress(subject);
|
|
131
|
-
return Promise.resolve(mine ? [localProject()] : []);
|
|
132
|
-
},
|
|
133
|
-
createProject: () => notHere('projects'),
|
|
134
|
-
renameProject: () => notHere('projects'),
|
|
135
|
-
deleteProject: () => notHere('projects'),
|
|
136
|
-
listMembers: (subject, id) => {
|
|
137
|
-
const owner = localOwner();
|
|
138
|
-
if (id !== LOCAL_PROJECT_ID || owner === null || owner !== normalizeAddress(subject))
|
|
139
|
-
return Promise.reject(new ApiError('no such project', 404));
|
|
140
|
-
return Promise.resolve([
|
|
141
|
-
{ id: 'owner', address: owner, email: null, role: 'admin', owner: true },
|
|
142
|
-
]);
|
|
143
|
-
},
|
|
144
|
-
addMember: () => notHere('members'),
|
|
145
|
-
setMemberRole: () => notHere('members'),
|
|
146
|
-
removeMember: () => notHere('members'),
|
|
147
|
-
};
|
|
148
|
-
|
|
149
114
|
function connectorIdsOfLocalAgents(ids: string[]): Promise<Map<string, string[]>> {
|
|
150
115
|
return Promise.resolve(new Map(ids.map((id) => [id, connectorIdsOfLocalAgent(id) ?? []] as const)));
|
|
151
116
|
}
|
|
@@ -211,7 +176,7 @@ function importApi(deps: LocalModeDeps): ImportApiDeps {
|
|
|
211
176
|
};
|
|
212
177
|
}
|
|
213
178
|
|
|
214
|
-
|
|
179
|
+
function localModeInfo(): ModeInfo {
|
|
215
180
|
return { mode: 'local', owner: localOwner(), project: LOCAL_PROJECT_ID };
|
|
216
181
|
}
|
|
217
182
|
|
|
@@ -225,8 +190,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
225
190
|
relayApi,
|
|
226
191
|
localCli,
|
|
227
192
|
claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
228
|
-
|
|
229
|
-
siwe: { ensureUser: claimLocalOwner },
|
|
193
|
+
siwe: { ensureUser: ownerSignIn },
|
|
230
194
|
mode: localModeInfo,
|
|
231
195
|
};
|
|
232
196
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { localOwner, setLocalOwner } from '../db/file-admin.js';
|
|
2
|
+
import { log } from './log.js';
|
|
3
|
+
|
|
4
|
+
export function applyLocalOwner(): string | null {
|
|
5
|
+
const wanted = process.env.METRO_OWNER?.trim() ?? '';
|
|
6
|
+
if (wanted !== '') {
|
|
7
|
+
const address = setLocalOwner(wanted);
|
|
8
|
+
log.info({ owner: address }, 'local daemon: owner set from METRO_OWNER');
|
|
9
|
+
return address;
|
|
10
|
+
}
|
|
11
|
+
const stored = localOwner();
|
|
12
|
+
if (stored === null) log.warn('local daemon: no owner set, so no wallet can sign in; restart with metro serve --owner <address>');
|
|
13
|
+
return stored;
|
|
14
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
type OAuthTokens,
|
|
5
5
|
} from './connector-verify.js';
|
|
6
6
|
import type { OAuthServer } from './oauth-discovery.js';
|
|
7
|
+
import { isRecord } from './is-record.js';
|
|
7
8
|
|
|
8
9
|
const TOKEN_TIMEOUT_MS = 15_000;
|
|
9
10
|
const CLIENT_NAME = 'Metro';
|
|
@@ -17,10 +18,6 @@ function refused(message: string): ConnectorVerifyError {
|
|
|
17
18
|
return new ConnectorVerifyError(message, 400);
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
21
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
21
|
export function newVerifier(): string {
|
|
25
22
|
return randomBytes(32).toString('base64url');
|
|
26
23
|
}
|
|
@@ -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)
|