@gemus/mcp-proxy 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # @gemus/mcp-proxy (Issues #1751, #1756, #1917)
2
2
 
3
3
  Local stdio↔HTTP MCP proxy that gives **Codex desktop (direct-connect / Mode B)** users canvas
4
- image write-back. Bundled into the Codex plugin (`.codex-plugin/`), declared as a **command-based**
5
- MCP server so codex spawns it locally; the proxy owns upstream auth to the remote gemus `/api/mcp`
6
- (Codex does no OAuth for stdio servers). Full design: `docs/plans/1751-codex-desktop-companion.md`
4
+ image write-back. The Codex plugin declares it as a **default-disabled, command-based** MCP server
5
+ so Codex can spawn it locally after explicit enablement; the proxy owns upstream auth to the remote
6
+ Gemus `/api/mcp` (Codex does no OAuth for stdio servers). Full design:
7
+ `docs/plans/1751-codex-desktop-companion.md`
7
8
  and `docs/architecture/agent architecture/codex-desktop-companion.md`.
8
9
 
9
10
  ## Status: implemented
@@ -27,24 +28,55 @@ server blueprint policy.
27
28
  - **(c) turn-end + retrieval** — `Stop` hook fires post-flush carrying `transcript_path` → exact
28
29
  rollout; imagegen writes `generated_images/<session>/<sanitized-call-id>.png` (historically `ig_*`,
29
30
  currently `call_*`) + rollout `saved_path`+base64.
30
- - **Session cap** — `KEY_ONLY_SESSION_CAP=3`/user is real; the proxy MUST `DELETE` its upstream
31
- session on close (implemented in `shutdown()`) to avoid leaking slots.
31
+ - **Session cap** — the key-only session cap defaults to 10 concurrent sessions per user
32
+ (`MCP_KEY_ONLY_SESSION_CAP`); the proxy MUST `DELETE` its upstream session on close (implemented
33
+ in `cleanup()`) to avoid leaking slots.
32
34
 
33
- ## Install (Codex desktop)
35
+ ## Companion setup and migration (Codex desktop)
34
36
 
35
- The key is supplied as a **literal** in the user's *local* codex config — codex does NOT interpolate
36
- `${VAR}` in an MCP server's `env`, and a public plugin manifest must never carry a key (Step-0.4
37
- findings). So the keyed MCP server is registered locally, not via the plugin manifest:
37
+ The plugin owns the non-secret process contract: exact `@gemus/mcp-proxy@0.1.8`,
38
+ `startup_timeout_sec = 60`, `tool_timeout_sec = 300`, and a default-disabled rollout. The user
39
+ environment owns `GEMUS_KEY` and optional `GEMUS_URL`; the public plugin and generated commands
40
+ never contain their values.
38
41
 
39
- ```bash
40
- # 1. Register the proxy as codex's gemus MCP server (writes a literal key into ~/.codex/config.toml).
41
- codex mcp add gemus --env GEMUS_KEY=mak_<your key> -- npx -y @gemus/mcp-proxy
42
- # (get a mak_ key from gemus.ai → Settings → MCP Keys; GEMUS_URL defaults to https://gemus.ai/api/mcp)
43
-
44
- # 2. Install the plugin (Stop hook + skills) from the gemus marketplace, then TRUST the hook:
45
- codex plugin add gemus@<marketplace>
46
- # In interactive codex run `/hooks` and trust the gemus Stop hook — REQUIRED for canvas backfill.
47
- # Without a trusted hook, images still land on the canvas (orphan node), just not the planned node.
42
+ Environment ownership and lifetime:
43
+
44
+ - **Windows / PowerShell:** persist the key in the Windows user environment and refresh the current
45
+ process.
46
+ - **macOS:** If changing login context, sign out and back in first; rerun the `launchctl setenv`
47
+ setup in the new login session; then fully quit and restart Codex and open a new task.
48
+ - **Linux:** export the key and launch Codex from the same terminal; no universal desktop-session
49
+ inheritance is assumed.
50
+
51
+ Companion and direct modes are mutually exclusive. The canonical new-user and legacy migration flow
52
+ is:
53
+
54
+ 1. Set `GEMUS_KEY` in the user environment.
55
+ 2. Remove any legacy global server (`codex mcp remove gemus`; not-found is harmless).
56
+ 3. Install with `codex plugin marketplace add Gemus-AI/gemus-codex-plugin`, then
57
+ `codex plugin add gemus@gemus`. Existing users refresh the marketplace snapshot and replace the
58
+ installed cache:
59
+
60
+ ```bash
61
+ codex plugin marketplace upgrade gemus
62
+ codex plugin remove gemus@gemus
63
+ codex plugin add gemus@gemus
64
+ ```
65
+ 4. Explicitly enable the plugin-scoped server:
66
+
67
+ ```toml
68
+ [plugins."gemus@gemus".mcp_servers.gemus]
69
+ enabled = true
70
+ ```
71
+
72
+ 5. Restart Codex and open a new task, then use `/hooks` to trust the Stop hook separately.
73
+
74
+ Intentional direct HTTP/OAuth users keep their global direct server and leave the plugin Companion
75
+ disabled:
76
+
77
+ ```toml
78
+ [plugins."gemus@gemus".mcp_servers.gemus]
79
+ enabled = false
48
80
  ```
49
81
 
50
82
  ## Local dev
@@ -52,7 +84,6 @@ codex plugin add gemus@<marketplace>
52
84
  ```bash
53
85
  pnpm --filter @gemus/codex-backfill-core build # proxy imports the built core at runtime
54
86
  GEMUS_KEY=mak_... GEMUS_URL=http://localhost:3000/api/mcp PROXY_LOG=/tmp/proxy.log node src/proxy.mjs
55
- # or register against dev: codex mcp add gemus --env GEMUS_KEY=mak_... GEMUS_URL=http://localhost:3000/api/mcp -- node <abs>/src/proxy.mjs
56
87
  ```
57
88
 
58
89
  Env: `GEMUS_KEY` (required), `GEMUS_URL` (default `https://gemus.ai/api/mcp`), `PROXY_LOG` (optional
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gemus/mcp-proxy",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Local stdio<->HTTP MCP proxy for Codex desktop: transparent passthrough + execute tap + imagegen backfill (Issue #1751, P1).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,254 @@
1
+ /**
2
+ * @gemus/mcp-proxy fail-fast (#2068) — single-pass upstream-disconnect correlation.
3
+ *
4
+ * Pure logic; the socket is an injected `realFetch` returning controllable `Response`s. Cases that
5
+ * involve the final-safety timer use fake timers with advanceTimersByTimeAsync (lesson: fake timers
6
+ * + real reader.read() promises need the async advance to flush continuations).
7
+ */
8
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
9
+ import { parseRequestIds, isCodexOriginated, createSseIdScanner, createFailFast, isJsonRpcResponse } from '../failFast.mjs'
10
+
11
+ const enc = new TextEncoder()
12
+
13
+ /** A response body that emits the given SSE frames then closes. */
14
+ function bodyOf(...frames: string[]): ReadableStream<Uint8Array> {
15
+ return new ReadableStream<Uint8Array>({
16
+ start(c) {
17
+ for (const f of frames) c.enqueue(enc.encode(f))
18
+ c.close()
19
+ },
20
+ })
21
+ }
22
+ /** A response body that never emits and never closes (zombie stream). */
23
+ function neverBody(): ReadableStream<Uint8Array> {
24
+ return new ReadableStream<Uint8Array>({ start() {} })
25
+ }
26
+ function sseRes(body: ReadableStream<Uint8Array> | null, init?: { status?: number; contentType?: string }) {
27
+ const status = init?.status ?? 200
28
+ return new Response(body, { status, headers: { 'content-type': init?.contentType ?? 'text/event-stream' } })
29
+ }
30
+ function resultFrame(id: number | string): string {
31
+ return `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id, result: { ok: true } })}\n\n`
32
+ }
33
+ function post(id: number | string, method = 'tools/call') {
34
+ return { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id, method, params: {} }) }
35
+ }
36
+ async function drain(res: Response): Promise<void> {
37
+ const r = res.body!.getReader()
38
+ for (;;) { const { done } = await r.read(); if (done) break }
39
+ }
40
+
41
+ describe('parseRequestIds', () => {
42
+ it('extracts the id from a POST JSON-RPC request', () => {
43
+ expect(parseRequestIds(post(42))).toEqual([42])
44
+ })
45
+ it('extracts all ids from a batch', () => {
46
+ const body = JSON.stringify([
47
+ { jsonrpc: '2.0', id: 1, method: 'a' },
48
+ { jsonrpc: '2.0', id: 2, method: 'b' },
49
+ ])
50
+ expect(parseRequestIds({ method: 'POST', body })).toEqual([1, 2])
51
+ })
52
+ it('returns [] for GET / DELETE / bodyless requests', () => {
53
+ expect(parseRequestIds({ method: 'GET' })).toEqual([])
54
+ expect(parseRequestIds({ method: 'DELETE' })).toEqual([])
55
+ expect(parseRequestIds({ method: 'POST' })).toEqual([])
56
+ })
57
+ it('returns [] for non-JSON body and for notifications (no id)', () => {
58
+ expect(parseRequestIds({ method: 'POST', body: 'not json' })).toEqual([])
59
+ expect(parseRequestIds({ method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', method: 'x' }) })).toEqual([])
60
+ })
61
+ })
62
+
63
+ describe('isCodexOriginated', () => {
64
+ it.each([
65
+ [42, true],
66
+ ['abc', true],
67
+ ['gemus-bf-3', false],
68
+ ['gemus-ping-9', false],
69
+ [null, false],
70
+ [undefined, false],
71
+ ])('id=%s → %s', (id, expected) => {
72
+ expect(isCodexOriginated(id as never)).toBe(expected)
73
+ })
74
+ })
75
+
76
+ describe('isJsonRpcResponse', () => {
77
+ it('is true for a result/error response, false for a request or notification', () => {
78
+ expect(isJsonRpcResponse({ id: 1, result: {} })).toBe(true)
79
+ expect(isJsonRpcResponse({ id: 1, error: {} })).toBe(true)
80
+ expect(isJsonRpcResponse({ id: 1, method: 'elicitation/create', params: {} })).toBe(false) // server→client request
81
+ expect(isJsonRpcResponse({ method: 'notifications/x' })).toBe(false)
82
+ expect(isJsonRpcResponse(null)).toBe(false)
83
+ })
84
+ })
85
+
86
+ describe('createSseIdScanner', () => {
87
+ it('yields the id of a complete RESPONSE frame; ignores comment lines', () => {
88
+ const s = createSseIdScanner()
89
+ expect(s.push(enc.encode(': keepalive\n\n'))).toEqual([])
90
+ expect(s.push(enc.encode(resultFrame(7)))).toEqual([7])
91
+ })
92
+ it('does NOT yield a server→client REQUEST id (has a method) — avoids id-collision premature settle', () => {
93
+ const s = createSseIdScanner()
94
+ const reqFrame = `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'elicitation/create', params: {} })}\n\n`
95
+ expect(s.push(enc.encode(reqFrame))).toEqual([])
96
+ })
97
+ it('buffers a half-frame split across chunks until its boundary arrives', () => {
98
+ const s = createSseIdScanner()
99
+ const frame = resultFrame(5)
100
+ const mid = Math.floor(frame.length / 2)
101
+ expect(s.push(enc.encode(frame.slice(0, mid)))).toEqual([])
102
+ expect(s.push(enc.encode(frame.slice(mid)))).toEqual([5])
103
+ })
104
+ it('handles CRLF line endings (normalizes to LF)', () => {
105
+ const s = createSseIdScanner()
106
+ const crlf = `event: message\r\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: 8, result: {} })}\r\n\r\n`
107
+ expect(s.push(enc.encode(crlf))).toEqual([8])
108
+ })
109
+ it('ignores non-JSON data payloads', () => {
110
+ const s = createSseIdScanner()
111
+ expect(s.push(enc.encode('data: not-json\n\n'))).toEqual([])
112
+ })
113
+ })
114
+
115
+ describe('createFailFast.fetch', () => {
116
+ it('injects a JSON-RPC error when the stream closes before the result', async () => {
117
+ const onLost = vi.fn()
118
+ const realFetch = vi.fn(async () => sseRes(bodyOf(': keepalive\n\n'))) // no result frame → lost
119
+ const ff = createFailFast({ onLost, realFetch })
120
+
121
+ const res = await ff.fetch('u', post(42))
122
+ await drain(res)
123
+
124
+ expect(onLost).toHaveBeenCalledTimes(1)
125
+ expect(onLost.mock.calls[0][0]).toBe(42)
126
+ expect(ff.failedIds.has('42')).toBe(true)
127
+ })
128
+
129
+ it('does NOT inject an error when the result crosses the wire before close', async () => {
130
+ const onLost = vi.fn()
131
+ const realFetch = vi.fn(async () => sseRes(bodyOf(resultFrame(42))))
132
+ const ff = createFailFast({ onLost, realFetch })
133
+
134
+ const res = await ff.fetch('u', post(42))
135
+ await drain(res)
136
+
137
+ expect(onLost).not.toHaveBeenCalled()
138
+ expect(ff.failedIds.has('42')).toBe(false)
139
+ })
140
+
141
+ it('fails fast immediately when realFetch throws before any response', async () => {
142
+ const onLost = vi.fn()
143
+ const realFetch = vi.fn(async () => { throw new Error('ECONNRESET') })
144
+ const ff = createFailFast({ onLost, realFetch })
145
+
146
+ await expect(ff.fetch('u', post(7))).rejects.toThrow('ECONNRESET')
147
+ expect(onLost).toHaveBeenCalledWith(7, expect.stringContaining('upstream fetch failed'))
148
+ expect(realFetch).toHaveBeenCalledTimes(1) // must NOT retry — a 2nd fetch could execute the tool
149
+ })
150
+
151
+ it('fails fast on a non-ok HTTP response', async () => {
152
+ const onLost = vi.fn()
153
+ const realFetch = vi.fn(async () => sseRes(null, { status: 404, contentType: 'application/json' }))
154
+ const ff = createFailFast({ onLost, realFetch })
155
+
156
+ await ff.fetch('u', post(7))
157
+ expect(onLost).toHaveBeenCalledWith(7, 'upstream HTTP 404')
158
+ })
159
+
160
+ it('passes GET and DELETE through untouched (no body → no monitoring, no onLost)', async () => {
161
+ const onLost = vi.fn()
162
+ const getRes = sseRes(bodyOf())
163
+ const realFetch = vi.fn(async () => getRes)
164
+ const ff = createFailFast({ onLost, realFetch })
165
+
166
+ const g = await ff.fetch('u', { method: 'GET' })
167
+ const d = await ff.fetch('u', { method: 'DELETE' })
168
+ expect(g).toBe(getRes)
169
+ expect(d).toBe(getRes)
170
+ expect(onLost).not.toHaveBeenCalled()
171
+ })
172
+
173
+ it('does not track internal gemus-bf-* / gemus-ping-* ids', async () => {
174
+ const onLost = vi.fn()
175
+ const realFetch = vi.fn(async () => sseRes(bodyOf(': keepalive\n\n'))) // closes with no result
176
+ const ff = createFailFast({ onLost, realFetch })
177
+
178
+ await drain(await ff.fetch('u', post('gemus-bf-1')))
179
+ await drain(await ff.fetch('u', post('gemus-ping-2')))
180
+ expect(onLost).not.toHaveBeenCalled()
181
+ })
182
+
183
+ it('degrades to plain passthrough if internal logic throws', async () => {
184
+ const onLost = vi.fn()
185
+ const badRes = { ok: true, status: 200, headers: { get() { throw new Error('boom') } }, body: null } as unknown as Response
186
+ const realFetch = vi.fn(async () => badRes)
187
+ const ff = createFailFast({ onLost, realFetch })
188
+
189
+ const res = await ff.fetch('u', post(1))
190
+ expect(res).toBe(badRes) // returns the response we ALREADY have — no re-fetch
191
+ expect(realFetch).toHaveBeenCalledTimes(1)
192
+ expect(onLost).not.toHaveBeenCalled()
193
+ ff.dispose()
194
+ })
195
+
196
+ it('isolates concurrent requests — disconnecting one does not affect the other', async () => {
197
+ const onLost = vi.fn()
198
+ const realFetch = vi
199
+ .fn()
200
+ .mockResolvedValueOnce(sseRes(bodyOf(': keepalive\n\n'))) // id 1 → dies with no result
201
+ .mockResolvedValueOnce(sseRes(bodyOf(resultFrame(2)))) // id 2 → delivers
202
+ const ff = createFailFast({ onLost, realFetch })
203
+
204
+ await drain(await ff.fetch('u', post(1)))
205
+ await drain(await ff.fetch('u', post(2)))
206
+
207
+ expect(onLost).toHaveBeenCalledTimes(1)
208
+ expect(onLost.mock.calls[0][0]).toBe(1)
209
+ })
210
+
211
+ describe('idle safety timeout', () => {
212
+ beforeEach(() => vi.useFakeTimers())
213
+ afterEach(() => vi.useRealTimers())
214
+
215
+ it('injects an error for a zombie connection that never emits a byte', async () => {
216
+ const onLost = vi.fn()
217
+ const realFetch = vi.fn(async () => sseRes(neverBody()))
218
+ const ff = createFailFast({ onLost, realFetch, idleTimeoutMs: 120_000 })
219
+
220
+ await ff.fetch('u', post(99)) // never emits → idle timer is never reset
221
+ expect(onLost).not.toHaveBeenCalled()
222
+ await vi.advanceTimersByTimeAsync(120_000)
223
+ expect(onLost).toHaveBeenCalledWith(99, expect.stringContaining('idle'))
224
+ ff.dispose()
225
+ })
226
+
227
+ it('does NOT kill a healthy-but-slow stream — heartbeat bytes reset the idle timer', async () => {
228
+ const onLost = vi.fn()
229
+ let ctl!: ReadableStreamDefaultController<Uint8Array>
230
+ const body = new ReadableStream<Uint8Array>({ start(c) { ctl = c } })
231
+ const realFetch = vi.fn(async () => sseRes(body))
232
+ const ff = createFailFast({ onLost, realFetch, idleTimeoutMs: 100 })
233
+
234
+ const res = await ff.fetch('u', post(1))
235
+ const reader = res.body!.getReader()
236
+
237
+ // Keepalive every 60ms (< 100ms idle) for 300ms total — each read touches → never fires.
238
+ for (let i = 0; i < 5; i++) {
239
+ ctl.enqueue(enc.encode(': keepalive\n\n'))
240
+ await reader.read()
241
+ await vi.advanceTimersByTimeAsync(60)
242
+ }
243
+ expect(onLost).not.toHaveBeenCalled() // survived 300ms > idleTimeout because bytes kept flowing
244
+
245
+ // Now the result arrives and the stream closes → still no fail-fast.
246
+ ctl.enqueue(enc.encode(resultFrame(1)))
247
+ await reader.read()
248
+ ctl.close()
249
+ await reader.read()
250
+ expect(onLost).not.toHaveBeenCalled()
251
+ ff.dispose()
252
+ })
253
+ })
254
+ })
@@ -0,0 +1,136 @@
1
+ function moduleUrl(source) {
2
+ return `data:text/javascript,${encodeURIComponent(source)}`
3
+ }
4
+
5
+ const markerPrelude = `
6
+ import fs from 'node:fs'
7
+ function mark(name) {
8
+ const file = process.env.PROXY_TEST_CONSTRUCTION_LOG
9
+ if (file) fs.appendFileSync(file, name + '\\n')
10
+ }
11
+ `
12
+
13
+ const modules = new Map([
14
+ ['@gemus/codex-backfill-core', `
15
+ export const CLIENT_CAPABILITIES = []
16
+ export const CLIENT_CAPABILITIES_HEADER = 'X-Gemus-Client-Capabilities'
17
+ export function serializeClientCapabilities(value) { return JSON.stringify(value) }
18
+ export function unwrapMcpContent(value) { return value }
19
+ `],
20
+ ['@modelcontextprotocol/sdk/server/stdio.js', `
21
+ ${markerPrelude}
22
+ export class StdioServerTransport {
23
+ constructor() { mark('stdio-transport') }
24
+ async start() {
25
+ if (process.env.PROXY_TEST_STDIO_START_FAILURE === '1') {
26
+ this.onclose?.()
27
+ throw new Error(
28
+ 'stdio failed Authorization: Bearer '
29
+ + process.env.GEMUS_KEY
30
+ + ' at '
31
+ + process.env.GEMUS_URL
32
+ )
33
+ }
34
+ if (process.env.PROXY_TEST_FORWARD_STDIN === '1') {
35
+ let input = ''
36
+ process.stdin.setEncoding('utf8')
37
+ process.stdin.on('data', (chunk) => {
38
+ input += chunk
39
+ for (;;) {
40
+ const newline = input.indexOf('\\n')
41
+ if (newline < 0) break
42
+ const line = input.slice(0, newline).trim()
43
+ input = input.slice(newline + 1)
44
+ if (line) this.onmessage?.(JSON.parse(line))
45
+ }
46
+ })
47
+ }
48
+ process.stdin.resume()
49
+ process.stdin.once('end', () => this.onclose?.())
50
+ }
51
+ async send() {}
52
+ }
53
+ `],
54
+ ['@modelcontextprotocol/sdk/client/streamableHttp.js', `
55
+ ${markerPrelude}
56
+ export class StreamableHTTPClientTransport {
57
+ constructor() { mark('upstream-transport') }
58
+ async start() {}
59
+ async send(message) {
60
+ if (
61
+ process.env.PROXY_TEST_UPSTREAM_INITIALIZE_FAILURE === '1'
62
+ && message?.method === 'initialize'
63
+ ) {
64
+ throw new Error(
65
+ 'initialize fetch failed Authorization: Bearer '
66
+ + process.env.GEMUS_KEY
67
+ + ' at '
68
+ + process.env.GEMUS_URL
69
+ )
70
+ }
71
+ }
72
+ async terminateSession() {}
73
+ async close() {}
74
+ }
75
+ `],
76
+ ['node:http', `
77
+ ${markerPrelude}
78
+ function hookError() {
79
+ return new Error(
80
+ 'hook listen failed Authorization: Bearer '
81
+ + process.env.GEMUS_KEY
82
+ + ' at '
83
+ + process.env.GEMUS_URL
84
+ )
85
+ }
86
+ const http = {
87
+ createServer() {
88
+ mark('hook-server')
89
+ const listeners = new Map()
90
+ return {
91
+ on(event, handler) { listeners.set(event, handler); return this },
92
+ listen(_port, _host, callback) {
93
+ if (process.env.PROXY_TEST_HOOK_LISTEN_FAILURE === '1') {
94
+ queueMicrotask(() => listeners.get('error')?.(hookError()))
95
+ } else {
96
+ queueMicrotask(callback)
97
+ }
98
+ return this
99
+ },
100
+ address() { return { port: 43114 } },
101
+ unref() {},
102
+ close() {
103
+ mark('hook-server-close')
104
+ if (process.env.PROXY_TEST_HOOK_ERROR_AFTER_CLOSE === '1') {
105
+ queueMicrotask(() => listeners.get('error')?.(hookError()))
106
+ }
107
+ },
108
+ }
109
+ },
110
+ }
111
+ export default http
112
+ `],
113
+ ['./backfill.mjs', `
114
+ ${markerPrelude}
115
+ export function createBackfiller() {
116
+ mark('backfiller')
117
+ return {
118
+ observeCall() {},
119
+ observeResult() {},
120
+ async backfillTurn() {},
121
+ }
122
+ }
123
+ export function injectSteering() { return false }
124
+ `],
125
+ ])
126
+
127
+ export async function resolve(specifier, context, nextResolve) {
128
+ const source = modules.get(specifier)
129
+ if (
130
+ source !== undefined
131
+ && (specifier !== './backfill.mjs' || context.parentURL?.endsWith('/proxy.mjs'))
132
+ ) {
133
+ return { url: moduleUrl(source), shortCircuit: true }
134
+ }
135
+ return nextResolve(specifier, context)
136
+ }