@stage-labs/metro 0.1.0-beta.260 → 0.1.0-beta.261

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.260",
3
+ "version": "0.1.0-beta.261",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.260",
3
+ "version": "0.1.0-beta.261",
4
4
  "description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
5
5
  "author": {
6
6
  "name": "Bonustrack",
@@ -25,6 +25,7 @@ import { asApiError, BODY_MAX, claudeLoginDeps, connectionFor, settingsBody, typ
25
25
  import { dropUnusedClaudeLogins } from './claude-logins.js';
26
26
  import { CODEX_ROUTES, codexDeviceRoute, GEMINI_ROUTES } from './model-signin.js';
27
27
  import { refreshUsage } from './usage-refresh.js';
28
+ import { handleOpenRouterSignIn } from './openrouter-signin.js';
28
29
 
29
30
  const PATH = '/api/model';
30
31
  const CONNECTIONS = '/api/model/connections';
@@ -230,11 +231,6 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
230
231
  res.writeHead(204, cors(req)).end();
231
232
  return true;
232
233
  }
233
- const route = routeFor(path, req.method);
234
- if (typeof route === 'number') {
235
- sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
236
- return true;
237
- }
238
234
  const store: Store = {
239
235
  read: deps.read ?? readModelConfig,
240
236
  write: (cfg) => {
@@ -242,6 +238,12 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
242
238
  (deps.write ?? writeModelConfig)(cfg);
243
239
  },
244
240
  };
241
+ if (handleOpenRouterSignIn(req, res, deps, store)) return true;
242
+ const route = routeFor(path, req.method);
243
+ if (typeof route === 'number') {
244
+ sendJson(req, res, route, { error: route === 404 ? 'not found' : 'method not allowed' });
245
+ return true;
246
+ }
245
247
  apiSession(req)
246
248
  .then(async (session) => {
247
249
  if (!session) throw new ApiError('unauthorized', 401);
@@ -21,6 +21,9 @@ export interface ModelApiDeps {
21
21
  geminiUserBase?: string;
22
22
  geminiBase?: string;
23
23
  openrouterBase?: string;
24
+ owner?: () => string | null;
25
+ openrouterPublicBase?: () => string | null;
26
+ openrouterNow?: () => number;
24
27
  anthropicBase?: string;
25
28
  bedrockControlBase?: string;
26
29
  setup?: SetupDeps;
@@ -0,0 +1,192 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { pkcePair, newState } from '@metro-labs/core/stations/oauth';
3
+ import { ticketStore } from '@metro-labs/core/tickets';
4
+ import { isRecord } from '@metro-labs/core/is-record';
5
+ import { log } from '@metro-labs/core/log';
6
+ import { ApiError } from '@metro-labs/http/api-error';
7
+ import { addConnection, connectionOf, updateConnection } from './model-config.js';
8
+ import { asApiError, type Store } from './model-store.js';
9
+ import { forgetReported } from './usage.js';
10
+
11
+ export const OPENROUTER_CALLBACK = '/api/model/openrouter/callback';
12
+ const TTL = 10 * 60_000;
13
+ const MAX_ATTEMPTS = 100;
14
+ const STALE = 'This OpenRouter sign-in expired or was cancelled. Start again.';
15
+ const FAILED = 'OpenRouter sign-in did not finish. Try again or use an API key. Manage any unused key in OpenRouter.';
16
+
17
+ type Result = { status: 'pending' } | { status: 'done'; connection: string } | { status: 'failed'; error: string };
18
+
19
+ interface Attempt {
20
+ owner: string;
21
+ connection: string;
22
+ keyHash: string;
23
+ expiresAt: number;
24
+ state: string;
25
+ verifier: string;
26
+ result: Result;
27
+ abort: AbortController;
28
+ }
29
+
30
+ interface LoginDeps {
31
+ store: Store;
32
+ owner: () => string | null;
33
+ publicBase: () => string | null;
34
+ fetchImpl?: typeof fetch;
35
+ now?: () => number;
36
+ }
37
+
38
+ const hash = (value: string): string => createHash('sha256').update(value).digest('hex');
39
+
40
+ function callbackUrl(base: string | null): string {
41
+ let url: URL;
42
+ try {
43
+ url = new URL(base ?? '');
44
+ } catch {
45
+ throw new ApiError('This box needs a public HTTPS address for OpenRouter sign-in. Use an API key instead.', 409);
46
+ }
47
+ if (url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '')
48
+ throw new ApiError('This box needs a public HTTPS origin for OpenRouter sign-in. Use an API key instead.', 409);
49
+ return `${url.origin}${OPENROUTER_CALLBACK}`;
50
+ }
51
+
52
+ async function exchange(code: string, verifier: string, signal: AbortSignal, fetchImpl: typeof fetch): Promise<string> {
53
+ const res = await fetchImpl('https://openrouter.ai/api/v1/auth/keys', {
54
+ method: 'POST',
55
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
56
+ body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: 'S256' }),
57
+ redirect: 'manual',
58
+ signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]),
59
+ });
60
+ if (!res.ok) {
61
+ await res.body?.cancel();
62
+ throw new ApiError(FAILED, 502);
63
+ }
64
+ const body: unknown = await res.json();
65
+ if (!isRecord(body) || typeof body.key !== 'string' || body.key.trim() === '' || body.key.length > 512)
66
+ throw new ApiError(FAILED, 502);
67
+ return body.key.trim();
68
+ }
69
+
70
+ function authorizationCode(params: URLSearchParams): string {
71
+ const code = params.get('code') ?? '';
72
+ if (params.has('error') || code === '' || code.length > 4096 || params.getAll('code').length !== 1 || params.getAll('state').length !== 1)
73
+ throw new ApiError('OpenRouter did not approve this sign-in. Try again or use an API key.', 400);
74
+ return code;
75
+ }
76
+
77
+ export class OpenRouterLogins {
78
+ private readonly states = ticketStore<Attempt>(TTL, MAX_ATTEMPTS);
79
+ private readonly attempts = new Map<string, Attempt>();
80
+
81
+ constructor(private readonly deps: LoginDeps) {}
82
+
83
+ private now(): number {
84
+ return (this.deps.now ?? Date.now)();
85
+ }
86
+
87
+ private discard(attempt: Attempt): void {
88
+ this.states.take(attempt.state, this.now());
89
+ attempt.verifier = '';
90
+ attempt.abort.abort();
91
+ }
92
+
93
+ private prune(): void {
94
+ for (const [id, attempt] of this.attempts) {
95
+ if (attempt.expiresAt > this.now()) continue;
96
+ this.discard(attempt);
97
+ this.attempts.delete(id);
98
+ }
99
+ }
100
+
101
+ private checkOwner(owner: string): void {
102
+ if (this.deps.owner() !== owner) throw new ApiError('This sign-in belongs to another organization.', 403);
103
+ }
104
+
105
+ private target(connection: string): string {
106
+ const cfg = this.deps.store.read();
107
+ if (connection === '') {
108
+ try {
109
+ addConnection(cfg, { provider: 'openrouter' });
110
+ } catch (err) {
111
+ asApiError(err);
112
+ }
113
+ return '';
114
+ }
115
+ const found = connectionOf(cfg, connection);
116
+ if (found?.provider !== 'openrouter') throw new ApiError('This OpenRouter connection no longer exists.', 404);
117
+ return hash(found.apiKey);
118
+ }
119
+
120
+ begin(owner: string, connection: string): { id: string; url: string; expiresAt: number } {
121
+ this.checkOwner(owner);
122
+ const callback = callbackUrl(this.deps.publicBase());
123
+ const keyHash = this.target(connection);
124
+ this.prune();
125
+ if (this.attempts.size >= MAX_ATTEMPTS) throw new ApiError('Too many OpenRouter sign-ins. Try again later.', 429);
126
+ const { verifier, challenge } = pkcePair();
127
+ const attempt: Attempt = { owner, connection, keyHash, verifier, state: '', expiresAt: this.now() + TTL, result: { status: 'pending' }, abort: new AbortController() };
128
+ attempt.state = this.states.mint(attempt, this.now()).ticket;
129
+ const id = newState();
130
+ this.attempts.set(id, attempt);
131
+ const url = new URL('https://openrouter.ai/auth');
132
+ url.search = new URLSearchParams({ callback_url: callback, code_challenge: challenge, code_challenge_method: 'S256', state: attempt.state, key_label: 'Metro' }).toString();
133
+ return { id, url: url.href, expiresAt: attempt.expiresAt };
134
+ }
135
+
136
+ private find(owner: string, id: string): Attempt {
137
+ this.checkOwner(owner);
138
+ this.prune();
139
+ const attempt = this.attempts.get(id);
140
+ if (attempt?.owner !== owner) throw new ApiError(STALE, 410);
141
+ return attempt;
142
+ }
143
+
144
+ status(owner: string, id: string): Result {
145
+ return this.find(owner, id).result;
146
+ }
147
+
148
+ cancel(owner: string, id: string): Result {
149
+ const attempt = this.find(owner, id);
150
+ if (attempt.result.status === 'pending') {
151
+ attempt.result = { status: 'failed', error: 'OpenRouter sign-in cancelled.' };
152
+ this.discard(attempt);
153
+ }
154
+ return attempt.result;
155
+ }
156
+
157
+ private checkPending(attempt: Attempt): void {
158
+ if (attempt.expiresAt <= this.now() || attempt.result.status !== 'pending') throw new ApiError(STALE, 410);
159
+ this.checkOwner(attempt.owner);
160
+ if (this.target(attempt.connection) !== attempt.keyHash)
161
+ throw new ApiError('This connection changed during sign-in. Start again.', 409);
162
+ }
163
+
164
+ private save(attempt: Attempt, key: string): void {
165
+ this.checkPending(attempt);
166
+ const current = this.deps.store.read();
167
+ const next = attempt.connection === '' ? addConnection(current, { provider: 'openrouter', apiKey: key }) : updateConnection(current, attempt.connection, { apiKey: key });
168
+ const id = attempt.connection === '' ? (next.connections.at(-1)?.id ?? '') : attempt.connection;
169
+ this.deps.store.write(next);
170
+ forgetReported(id);
171
+ attempt.result = { status: 'done', connection: id };
172
+ log.info({ connection: id }, 'model-api: OpenRouter connected');
173
+ }
174
+
175
+ async complete(params: URLSearchParams): Promise<void> {
176
+ const state = params.get('state') ?? '';
177
+ const attempt = this.states.take(state, this.now());
178
+ if (attempt === undefined) throw new ApiError(STALE, 410);
179
+ const verifier = attempt.verifier;
180
+ attempt.verifier = '';
181
+ try {
182
+ this.checkPending(attempt);
183
+ const key = await exchange(authorizationCode(params), verifier, attempt.abort.signal, this.deps.fetchImpl ?? fetch);
184
+ this.save(attempt, key);
185
+ } catch (err) {
186
+ if (attempt.result.status === 'pending') attempt.result = { status: 'failed', error: err instanceof ApiError ? err.message : FAILED };
187
+ throw new ApiError(attempt.result.status === 'failed' ? attempt.result.error : FAILED, 400);
188
+ } finally {
189
+ this.discard(attempt);
190
+ }
191
+ }
192
+ }
@@ -0,0 +1,64 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import { sendJson, sessionRoute } from '@metro-labs/http/api-http';
3
+ import { log } from '@metro-labs/core/log';
4
+ import { publicBaseUrl } from '../files/attach-serve.js';
5
+ import { askedConnection, type ModelApiDeps, type Store } from './model-store.js';
6
+ import { OPENROUTER_CALLBACK, OpenRouterLogins } from './openrouter-login.js';
7
+
8
+ const LOGIN = '/api/model/openrouter/login';
9
+ const ID_RE = /^[A-Za-z0-9_-]{32}$/;
10
+ const logins = new WeakMap<ModelApiDeps, OpenRouterLogins>();
11
+ const CALLBACK_HEADERS = {
12
+ 'cache-control': 'no-store',
13
+ 'referrer-policy': 'no-referrer',
14
+ 'content-security-policy': "default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
15
+ 'x-content-type-options': 'nosniff',
16
+ };
17
+
18
+ function loginFor(deps: ModelApiDeps, store: Store): OpenRouterLogins {
19
+ let login = logins.get(deps);
20
+ if (login === undefined) {
21
+ login = new OpenRouterLogins({
22
+ store,
23
+ owner: () => deps.owner?.() ?? null,
24
+ publicBase: deps.openrouterPublicBase ?? publicBaseUrl,
25
+ fetchImpl: deps.fetchImpl,
26
+ now: deps.openrouterNow,
27
+ });
28
+ logins.set(deps, login);
29
+ }
30
+ return login;
31
+ }
32
+
33
+ function callback(req: IncomingMessage, res: ServerResponse, login: OpenRouterLogins): void {
34
+ if (req.method !== 'GET') {
35
+ sendJson(req, res, 405, { error: 'method not allowed' });
36
+ return;
37
+ }
38
+ const url = new URL(req.url ?? '', 'https://metro');
39
+ if (url.search === '') {
40
+ res.writeHead(200, { ...CALLBACK_HEADERS, 'content-type': 'text/html; charset=utf-8' });
41
+ res.end('<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>OpenRouter sign-in</title><h1>Return to Metro</h1><p>Check your connection in Metro. You can close this tab.</p><p>If you declined access, cancel the sign-in in Metro or use an API key.</p></html>');
42
+ return;
43
+ }
44
+ const finish = (): void => { res.writeHead(303, { ...CALLBACK_HEADERS, location: OPENROUTER_CALLBACK }).end(); };
45
+ login.complete(url.searchParams).then(finish, finish).catch(() => {
46
+ log.warn('openrouter-signin: callback response closed');
47
+ if (!res.writableEnded) res.end();
48
+ });
49
+ }
50
+
51
+ export function handleOpenRouterSignIn(req: IncomingMessage, res: ServerResponse, deps: ModelApiDeps, store: Store): boolean {
52
+ const path = (req.url ?? '').split('?')[0] ?? '';
53
+ if (path === OPENROUTER_CALLBACK) {
54
+ callback(req, res, loginFor(deps, store));
55
+ return true;
56
+ }
57
+ const id = path.startsWith(`${LOGIN}/`) ? path.slice(LOGIN.length + 1) : '';
58
+ if (path !== LOGIN && !ID_RE.test(id)) return false;
59
+ return sessionRoute(req, res, { methods: { [path]: path === LOGIN ? ['POST'] : ['GET', 'DELETE'] }, admin: false, label: 'openrouter-signin' }, (session) => {
60
+ const login = loginFor(deps, store);
61
+ if (path === LOGIN) return Promise.resolve(login.begin(session.subject, askedConnection(req)));
62
+ return Promise.resolve(req.method === 'DELETE' ? login.cancel(session.subject, id) : login.status(session.subject, id));
63
+ });
64
+ }
@@ -202,7 +202,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
202
202
  controlApi: { restart: deps.restart, stop: deps.stop },
203
203
  ownerApi: { setOwner: (owner) => setLocalOwner(owner) },
204
204
  machineApi: {},
205
- modelApi: { switchModel: deps.switchModel },
205
+ modelApi: { switchModel: deps.switchModel, owner: localOwner },
206
206
  gateway: { config: readModelConfig },
207
207
  terminalApi: {},
208
208
  mode: localModeInfo,
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.260"
2
+ "version": "0.1.0-beta.261"
3
3
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.260",
2
+ "version": "0.1.0-beta.261",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",