Repository navigation
Expand file tree
/
Copy pathindex.js
More file actions
1357 lines (1242 loc) · 51.1 KB
/
Copy pathindex.js
File metadata and controls
1357 lines (1242 loc) · 51.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* opencode-fetch-proxy
* ----------------------------------------------------------------------------
* Route OpenCode provider traffic through an HTTP proxy, for one provider or a
* list of target hosts. Built for the corporate shape where the VPN proxy is the
* only route to a model server on the LAN, while the public internet is reachable
* normally -- without changing the system proxy.
*
* Verified against @opencode-ai/plugin 1.18.25 / 1.19.0 (V1 plugin API) and the
* OpenCode 1.19 desktop sidecar, which runs on Node (not Bun).
*
* WHY THIS PATCHES fetch:
* the V1 plugin API exposes only: config, provider, auth, event, tool, dispose,
* chat.message, chat.params, chat.headers, permission.ask, tool.execute.* and
* experimental.*. No hook can attach a proxy to a provider request, and provider
* `options` is the only schema-legal place for extra per-provider settings
* (ProviderConfig is additionalProperties:false; options is an open record).
* So this wraps the runtime `fetch` the AI SDK providers fall back to.
*
* HOW A PROXY IS CHOSEN FOR A URL (first match wins):
* 1. options.proxy / settings.proxy declared on that provider
* 2. proxy-providers.json written by the /proxy command
* 3. proxy.json `proxy` only for `targets`, or for every provider
* when routeAllProviders is enabled
* Loopback is never proxied, unconditionally.
*/
import fs from "node:fs"
import net from "node:net"
import tls from "node:tls"
import os from "node:os"
import path from "node:path"
import { Readable } from "node:stream"
import { fileURLToPath } from "node:url"
const MARKER = Symbol.for("opencode.fetch.proxy.installed")
// Newline constant: keeps the message strings readable without escape soup.
const NL = String.fromCharCode(10)
/* ------------------------------------------------------------------ config */
/**
* OpenCode resolves its global config directory to ~/.config/opencode on every
* platform, including Windows (confirmed from its own startup log). It does not
* use %APPDATA%\opencode.
*/
function configDir() {
if (process.env.OPENCODE_CONFIG_DIR) return process.env.OPENCODE_CONFIG_DIR
const xdg = process.env.XDG_CONFIG_HOME
if (xdg) return path.join(xdg, "opencode")
return path.join(os.homedir(), ".config", "opencode")
}
function str(v) {
return typeof v === "string" && v.trim() ? v.trim() : ""
}
function loadFileConfig() {
const file = path.join(configDir(), "proxy.json")
let fromFile = {}
try {
fromFile = JSON.parse(fs.readFileSync(file, "utf8"))
} catch (err) {
if (err && err.code !== "ENOENT") console.error("[fetch-proxy] bad proxy.json:", err.message)
}
const rawTargets = fromFile.targets ?? []
return {
proxy: str(fromFile.proxy),
targets: (Array.isArray(rawTargets) ? rawTargets : [rawTargets]).map(str).filter(Boolean),
// OFF by default. Routing every provider through one proxy is wrong when only one
// destination sits behind it: unrelated providers would depend on that proxy and
// their traffic would go somewhere it need not go.
routeAllProviders:
process.env.OPENCODE_PROXY_ALL === "1" || fromFile.routeAllProviders === true,
log: fromFile.log === true,
file,
}
}
/**
* Slash commands are NOT registrable from a plugin in this build -- the command list is
* built from the `command` config map, markdown files, MCP prompts and skills only, and
* the `source` union has no "plugin" member. The supported way to ship one is a markdown
* file under <config>/command/, so the plugin writes and maintains one.
*
* Discovered from markdown files in command/ and commands/ under ~/.config/opencode,
* ~/.opencode, and every .opencode up to the worktree root. Frontmatter keys:
* description, agent, model, variant, subtask. The body is the template; $ARGUMENTS is
*/
function commandFilePath() {
return path.join(configDir(), "command", "proxy.md")
}
/**
* Where set-proxy.js lives, if it shipped alongside this file. Bundling it is what lets the
* /proxy command do real work without a model turn, so its absence matters and is reported.
*/
function helperScriptPath() {
try {
const here = path.dirname(fileURLToPath(import.meta.url))
// lib/ first. The discovery glob is {plugin,plugins}/*.{ts,js} -- a single path segment,
// never a directory -- so a subdirectory is never imported as a plugin. Shipping the helper
// as a direct sibling put it INSIDE that glob, and OpenCode imported it at every startup: it
// has no exports, so its CLI top level ran and opened the browser form on each launch.
const candidates = [path.join(here, "lib", "set-proxy.js"), path.join(here, "set-proxy.js")]
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate
}
return ""
} catch {
return ""
}
}
/**
* The /proxy command, as a SHELL-BACKED command rather than a prompt template.
*
* Why this shape: a slash command whose body is a prompt still needs a model turn to run its
* tool, so it cannot configure the only provider -- that provider is unreachable until
* configured. But the server expands `!`...`` in a command template with a real shell BEFORE it
* calls the model (SessionPrompt.command: shellMatches -> prompt()). So a command can have a
* side effect with no turn at all. That is the whole trick.
*
* Nothing the user types reaches the command line: the only variable text is a base64 payload,
* and base64 cannot contain a shell metacharacter or require quoting. decode happens in the
* script, which reads stdin.
*
* Falls back to the prompt-template form when set-proxy.js is not beside this file, so a
* single-file install still behaves as before instead of breaking.
*/
function proxyCommandBody() {
const helper = helperScriptPath()
if (!helper) return PROMPT_TEMPLATE_BODY
const NLc = String.fromCharCode(10)
// Forward slashes, not JSON.stringify's backslashes: this string is handed to a real shell,
// and a Windows path in backslashes loses them to escape processing ("C:\U..." -> "C:U...").
//
// $ARGUMENTS is passed as a command-line argument, NOT piped through echo: cmd.exe answers a
// bare `echo` with the literal text "ECHO is on.", so an argument-less /proxy would push that
// down the pipe. argv has no such failure mode and keeps a single code path.
const quoted = '"' + helper.replace(/\\/g, "/") + '"'
return [
"---",
"description: Set or remove the HTTP proxy for one provider (works with no reachable model)",
"---",
"!`node " + quoted + " proxy-cmd --silent $ARGUMENTS`",
"",
// Nothing after the shell substitution. The expansion captures no output (--silent), so
// the whole template resolves to an empty string: there is no prompt for a model to answer,
// and the result reaches the user through the noReply message the plugin posts instead.
].join(NLc)
}
/** The original prompt-template form, used only when set-proxy.js is unavailable. */
const PROMPT_TEMPLATE_BODY = [
"---",
"description: Set or remove the HTTP proxy for one provider",
"---",
"Run set_proxy once with these arguments, then reply with its output and nothing else: $ARGUMENTS",
].join(String.fromCharCode(10))
const PROXY_COMMAND_BODY = PROMPT_TEMPLATE_BODY
// Kept short on purpose: this template is shown in the transcript, so a verbose one
// produces a wall of text on every use. Guidance belongs in the tool description.
/** Writes the command file if absent or out of date. Never throws. */
function ensureProxyCommandFile() {
const file = commandFilePath()
const want = proxyCommandBody()
try {
let existing = ""
try {
existing = fs.readFileSync(file, "utf8")
} catch {
/* not there yet */
}
if (existing.trim() === want.trim()) return { file, action: "unchanged" }
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, want + "\n", "utf8")
return { file, action: existing ? "updated" : "created" }
} catch (err) {
return { file, action: "failed", error: String(err && err.message ? err.message : err) }
}
}
/**
* Reads an `options.proxy` declaration out of opencode.jsonc, if any, so the tool can
* report a proxy it does not own. Deliberately tolerant: this only needs to find a string,
* not to parse JSONC properly, and any failure returns an empty string.
*/
/**
* The single non-loopback provider declared in the config, or "" when there is not exactly
* one. Used so /proxy works on the first try in a fresh session, where the selected provider
* is not yet known but is also not in question.
*/
function soleProviderID() {
const dir = configDir()
for (const name of ["opencode.jsonc", "opencode.json"]) {
try {
const text = fs.readFileSync(path.join(dir, name), "utf8")
const stripped = text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^[ \t]*\/\/.*$/gm, "")
const parsed = JSON.parse(stripped)
const providers = parsed && parsed.provider
if (!providers || typeof providers !== "object") continue
const usable = []
for (const [id, p] of Object.entries(providers)) {
const base = str(p?.options?.baseURL) || str(p?.settings?.baseURL) || str(p?.api)
if (!base) continue
let host = ""
try {
host = new URL(base).hostname.toLowerCase()
} catch {
// An unparseable baseURL disqualifies this candidate but must not abort the scan.
continue
}
if (isLoopback(host)) continue
usable.push(id)
}
if (usable.length === 1) return usable[0]
return ""
} catch {
/* not strict JSON, or unreadable: give up rather than guess */
}
}
return ""
}
/**
* Display name of the first model on a provider, from config. Used only to label output when
* the session has not supplied a name yet.
*/
function soleProviderModel(id) {
const dir = configDir()
for (const name of ["opencode.jsonc", "opencode.json"]) {
try {
const text = fs.readFileSync(path.join(dir, name), "utf8")
const stripped = text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^[ \t]*\/\/.*$/gm, "")
const p = JSON.parse(stripped)?.provider?.[id]
const models = p?.models
if (!models || typeof models !== "object") return ""
const first = Object.values(models)[0]
return str(first?.name) || str(Object.keys(models)[0])
} catch {
/* try the next candidate */
}
}
return ""
}
function readDeclaredProxy() {
const dir = configDir()
for (const name of ["opencode.jsonc", "opencode.json"]) {
try {
const text = fs.readFileSync(path.join(dir, name), "utf8")
// Strip comments so a commented-out proxy is not mistaken for a live one.
const stripped = text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^[ \t]*\/\/.*$/gm, "")
const m = /"proxy"\s*:\s*"([^"]+)"/.exec(stripped)
if (m && m[1]) return m[1]
} catch {
/* try the next candidate */
}
}
return ""
}
function proxyProvidersPath() {
return path.join(configDir(), "proxy-providers.json")
}
/**
* Mapping entries are { proxy, model } so the file stays readable by a human. A bare
* string is still accepted, so files written by an earlier version keep working.
*/
function mappingProxy(entry) {
if (typeof entry === "string") return str(entry)
return entry && typeof entry === "object" ? str(entry.proxy) : ""
}
function mappingModel(entry) {
return entry && typeof entry === "object" ? str(entry.model) : ""
}
function readProxyProviders(file) {
let raw
try {
raw = fs.readFileSync(file, "utf8")
} catch (err) {
if (err && err.code !== "ENOENT") {
console.error("[fetch-proxy] cannot read proxy-providers.json:", err.message)
}
return {}
}
try {
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}
const out = {}
for (const [k, v] of Object.entries(parsed)) {
const value = mappingProxy(v)
if (value) out[k] = v
}
return out
} catch (err) {
console.error("[fetch-proxy] bad proxy-providers.json:", err.message)
return {}
}
}
function writeProxyProviders(file, mapping) {
fs.writeFileSync(file, JSON.stringify(mapping, null, 2) + "\n", "utf8")
}
/**
* A backslash in the authority (e.g. DOMAIN\user) makes WHATWG URL parsing drop the
* credentials entirely, so the proxy would be contacted unauthenticated and fail with a
* confusing 407. Detect it and tell the user how to fix it instead of letting it fail.
*/
function malformedCredentials(proxy) {
const m = /^([a-z][a-z0-9+.-]*:\/\/)([^/?#]*)$/i.exec(str(proxy))
if (!m) return false
const authority = m[2]
if (!authority.includes("@")) return false
// Strip the real userinfo, then look for leftovers that mean it was not parsed.
try {
const u = new URL(proxy)
return !u.username && !u.password && authority.includes(":")
} catch {
return true
}
}
/** The effective port for a URL, so "host" and "host:port" are comparable. */
function hostPortOf(url) {
const u = new URL(url)
const port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80
return `${u.hostname.toLowerCase()}:${port}`
}
/** Normalize a target entry: URL, "host", or "host:port" all become "host:port". */
function normalizeTarget(entry) {
let t = str(entry)
if (!t) return ""
if (t.includes("://")) {
try {
return hostPortOf(t)
} catch {
/* fall through */
}
}
return t.toLowerCase().replace(/\/+$/, "")
}
function globToRegExp(pattern) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^:]*")
return new RegExp(`^${escaped}$`)
}
/**
* Loopback is never proxied, and this is deliberately not configurable.
*
* OpenCode's own client/server traffic is loopback, so routing it through a proxy
* creates a routing loop. And a proxy reached over the network usually has no route
* back to your loopback, so the request dies with an opaque error (typically 503)
* rather than anything actionable.
*/
function isLoopback(host) {
const h = host.replace(/^\[|\]$/g, "")
return (
h === "localhost" ||
h === "::1" ||
h === "0.0.0.0" ||
h.endsWith(".localhost") ||
/^127\./.test(h)
)
}
function matchesTarget(url, targets) {
if (targets.length === 0 || targets.includes("*")) return true
const hp = hostPortOf(url)
const host = hp.split(":")[0]
for (const raw of targets) {
const t = normalizeTarget(raw)
if (!t) continue
if (t.includes("*")) {
if (globToRegExp(t).test(hp) || globToRegExp(t).test(host)) return true
continue
}
if (t === hp || t === host) return true
}
return false
}
/**
* Proxy rules for one provider entry.
*
* Precedence: a proxy declared on the provider, then the /proxy mapping, then the
* global router (only when routeAllProviders is on).
*/
function providerProxyEntries(id, p, router, byProvider) {
if (!p || typeof p !== "object") return []
const baseURL = str(p?.options?.baseURL) || str(p?.settings?.baseURL) || str(p?.api)
const host = baseURL ? normalizeTarget(baseURL) : ""
if (host && isLoopback(host.split(":")[0])) return []
const declared = str(p?.options?.proxy) || str(p.proxy) || str(p?.settings?.proxy)
const mapped = mappingProxy((byProvider ?? {})[id])
const proxy = declared || mapped || router
if (!proxy) return []
const own = p?.options?.proxyTargets ?? p?.proxyTargets ?? p?.options?.noProxy ?? p?.noProxy
const targets = (Array.isArray(own) ? own : own ? [own] : []).map(str).filter(Boolean)
return [
{
id,
proxy,
host,
targets,
declared: Boolean(declared),
mapped: Boolean(mapped) && !declared,
},
]
}
/**
* Rule registry keyed by "host:port".
* exact: Map<"host:port", proxy>
* hosts: Map<"host", proxy> when no port was given
* globs: Array<[RegExp, proxy]>
*/
function buildRegistry(cfg, inline, byProvider = {}) {
const exact = new Map()
const hosts = new Map()
const globs = []
const addHost = (target, proxy) => {
if (!target) return
if (isLoopback(target.split(":")[0])) return
if (target.includes(":")) exact.set(target, proxy)
else hosts.set(target, proxy)
}
const add = (proxy, host, targets) => {
if (!proxy) return
for (const raw of targets) {
const t = normalizeTarget(raw)
if (!t) continue
if (t === "*") globs.push([/.*/, proxy])
else if (t.includes("*")) globs.push([globToRegExp(t), proxy])
else addHost(t, proxy)
}
if (host) addHost(host, proxy)
}
// Any host a provider actually uses.
for (const e of inline) {
if (e.host) addHost(e.host, e.proxy)
}
// Global proxy: explicit targets always; provider hosts only when opted in.
if (cfg.proxy) {
if (cfg.routeAllProviders) {
for (const e of inline) if (e.host) addHost(e.host, cfg.proxy)
}
add(cfg.proxy, "", cfg.targets)
}
return { exact, hosts, globs }
}
/* ------------------------------- local answer for the /proxy command turn */
/**
* Armed while a /proxy command turn is in flight, so its model request is answered locally.
*
* The command mechanism calls the model unconditionally and a plugin cannot cancel that -- but
* the call travels through this plugin patched global fetch, so it can be answered here,
* in-process. No HTTP server, no socket, no network.
*
* Why it matters: `/proxy remove` disconnects a provider that is reachable only through the
* proxy, so the real request would retry for about a minute and then surface a connection error
* for a command that had already succeeded.
*
* A timestamp rather than a flag, so an abandoned turn cannot swallow a later real request.
*/
const commandTurn = { armedUntil: 0 }
/** Arm the local answer. Called from the command hook, immediately before the turn. */
function armLocalAnswer() {
commandTurn.armedUntil = Date.now() + 5 * 60 * 1000
}
/**
* The completion the /proxy turn receives. Shaped as an OpenAI chat completion because that is
* what an openai-compatible provider parses. An empty assistant message is the honest answer:
* the command already reported its result through the plugin, so the turn has nothing to say.
*/
function stubCompletion(model) {
return {
id: "chatcmpl-proxy-command",
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: str(model) || "proxy-command",
choices: [{ index: 0, message: { role: "assistant", content: "" }, finish_reason: "stop" }],
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
}
}
/** The same completion as an SSE stream, for providers that ask for one. */
function stubStream(model) {
const name = str(model) || "proxy-command"
const frame = (delta, finish) =>
"data: " +
JSON.stringify({
id: "chatcmpl-proxy-command",
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: name,
choices: [{ index: 0, delta, finish_reason: finish ?? null }],
}) +
String.fromCharCode(10, 10)
return (
frame({ role: "assistant", content: "" }) +
frame({}, "stop") +
"data: [DONE]" +
String.fromCharCode(10, 10)
)
}
function proxyFor(url, registry) {
let hp = ""
try {
hp = hostPortOf(url)
} catch {
return null
}
const host = hp.split(":")[0]
if (isLoopback(host)) return null
const hit = registry.exact.get(hp) ?? registry.hosts.get(host)
if (hit) return hit
for (let i = registry.globs.length - 1; i >= 0; i--) {
const [re, proxy] = registry.globs[i]
if (re.test(hp) || re.test(host)) return proxy
}
return null
}
/* -------------------------------------------------------- proxy transports */
/**
* Preferred path: undici's ProxyAgent, which implements CONNECT tunnelling and
* proxy basic-auth for http and https targets. Not always resolvable, so the
* manual tunnel below is a real fallback rather than a theoretical one.
*/
async function makeDispatcher(proxyUrl) {
try {
const undici = await import("undici")
const ProxyAgent = undici.ProxyAgent ?? undici.default?.ProxyAgent
if (!ProxyAgent) return { dispatcher: null, via: "manual CONNECT tunnel" }
return { dispatcher: new ProxyAgent({ uri: proxyUrl }), via: "undici ProxyAgent" }
} catch {
return { dispatcher: null, via: "manual CONNECT tunnel" }
}
}
/** Opens a CONNECT tunnel and speaks HTTP/1.1 over it, streamed back as a Response. */
async function tunnelledFetch(url, init, proxyUrl, shouldLog) {
const target = new URL(url)
const proxy = new URL(proxyUrl)
const secure = target.protocol === "https:"
if (!secure && target.protocol !== "http:") {
throw new Error(`[fetch-proxy] unsupported target protocol: ${target.protocol}`)
}
const port = target.port ? Number(target.port) : secure ? 443 : 80
const socket = await new Promise((resolve, reject) => {
const s = net.connect(Number(proxy.port || 3128), proxy.hostname, () => resolve(s))
s.on("error", reject)
s.setTimeout(30000, () => reject(new Error("proxy connect timeout")))
})
try {
const auth =
proxy.username || proxy.password
? "Proxy-Authorization: Basic " +
Buffer.from(
`${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`,
).toString("base64") +
"\r\n"
: ""
await new Promise((resolve, reject) => {
let buf = ""
const onData = (chunk) => {
buf += chunk.toString("latin1")
const end = buf.indexOf("\r\n\r\n")
if (end === -1) return
socket.removeListener("data", onData)
const statusLine = buf.slice(0, buf.indexOf("\r\n"))
if (!/^HTTP\/1\.[01] 200/.test(statusLine)) {
return reject(new Error("proxy CONNECT failed: " + statusLine))
}
const leftover = buf.slice(end + 4)
if (leftover.length) socket.unshift(Buffer.from(leftover, "latin1"))
socket.setTimeout(0)
resolve()
}
socket.on("data", onData)
socket.on("error", reject)
socket.write(
`CONNECT ${target.hostname}:${port} HTTP/1.1\r\nHost: ${target.hostname}:${port}\r\n${auth}\r\n`,
)
})
const transport = secure
? await new Promise((resolve, reject) => {
const s = tls.connect({ socket, servername: target.hostname }, () => resolve(s))
s.on("error", reject)
})
: socket
const headers = {}
new Headers(init?.headers || {}).forEach((v, k) => {
if (k.toLowerCase() !== "host" && k.toLowerCase() !== "connection") headers[k] = v
})
headers["host"] = target.port ? `${target.hostname}:${target.port}` : target.hostname
headers["connection"] = "close"
const bodyBuf = init?.body ? Buffer.from(init.body) : null
if (bodyBuf) headers["content-length"] = String(bodyBuf.length)
let raw = `${(init?.method || "GET").toUpperCase()} ${target.pathname + target.search} HTTP/1.1\r\n`
for (const [k, v] of Object.entries(headers)) raw += `${k}: ${v}\r\n`
raw += "\r\n"
if (shouldLog()) console.error(`[fetch-proxy] tunnel ${init?.method || "GET"} ${url}`)
await new Promise((resolve, reject) => {
transport.write(raw, "latin1", (err) => (err ? reject(err) : resolve()))
if (bodyBuf) transport.write(bodyBuf)
})
const { status, headers: resHeaders, rest } = await new Promise((resolve, reject) => {
let buf = Buffer.alloc(0)
const onData = (chunk) => {
buf = Buffer.concat([buf, chunk])
const end = buf.indexOf("\r\n\r\n")
if (end === -1) return
transport.removeListener("data", onData)
const lines = buf.slice(0, end).toString("latin1").split("\r\n")
const m = /^HTTP\/1\.[01] (\d{3})/.exec(lines[0])
if (!m) return reject(new Error("bad response: " + lines[0]))
const out = {}
for (const line of lines.slice(1)) {
const i = line.indexOf(":")
if (i > 0) out[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim()
}
resolve({ status: Number(m[1]), headers: out, rest: buf.slice(end + 4) })
}
transport.on("data", onData)
transport.on("error", reject)
transport.on("close", () => reject(new Error("socket closed before response headers")))
})
const webStream = Readable.toWeb(
(() => {
if (rest.length) transport.unshift(rest)
return transport
})(),
)
return new Response(webStream, { status, headers: resHeaders })
} catch (err) {
socket.destroy()
throw err
}
}
/* -------------------------------------------------------------- proxy tool */
/** Input schema for the tool. Plain JSON Schema so it needs no SDK import. */
const PROXY_TOOL_INPUT = {
type: "object",
properties: {
providerID: {
type: "string",
description: "Provider id exactly as it appears in opencode.jsonc, for example 063.",
},
proxy: {
type: "string",
description: "Proxy URL for that provider, for example http://192.168.10.101:3128.",
},
remove: {
type: "boolean",
description: "Set true to remove the proxy for this provider and connect directly.",
},
},
required: ["providerID"],
additionalProperties: false,
}
/**
* Builds the tool the agent calls when /proxy runs.
*
* Prefers the SDK helper (which coerces the schema into what OpenCode expects) and
* falls back to a plain object, so the file stays dependency-free.
*/
function buildSetProxyTool(toolHelper, state, persist, getRegistry, readDeclaredProxy) {
const run = async (raw) => {
const args = raw && typeof raw === "object" ? raw : {}
// Resolve the provider: explicit id, else whatever this session is already using.
// The model is already selected, so the user should not have to look up its id.
const providerID = str(args.providerID) || str(state.currentProviderID)
// Fall back to the only provider there is. In a fresh session /proxy cannot know which
// provider is selected -- chat.params has not fired for a successful turn yet -- but if
// exactly one non-loopback provider is configured, the answer is unambiguous.
// If we fell back to the sole provider, its configured model name is the best available
// label -- the session has not told us one yet.
const sole = soleProviderID()
const resolvedID = providerID || sole
const soleModel = !providerID && sole ? soleProviderModel(sole) : ""
// Prefer a human label. The provider id is an opaque key like "063"; the model name
// is what the user recognises in the model picker.
const model =
str(args.model) ||
str(state.currentModelName) ||
str(state.currentModelID) ||
soleModel
const providerLabel = model
? model + " (provider " + resolvedID + ")"
: "provider " + resolvedID
const all = Object.entries(state.byProvider)
.map(([k, v]) => {
const label = mappingModel(v)
return label ? label + " -> " + redactProxy(mappingProxy(v)) : k + " -> " + redactProxy(mappingProxy(v))
})
.join("; ")
if (!resolvedID) {
return {
title: "Proxy: no provider yet",
output:
"I cannot tell which provider you mean yet. **Send one message first** so a " +
"provider is chosen, or name it explicitly:" + NL + NL +
"```" + NL + "/proxy <resolvedID> <proxyURL>" + NL + "```" + NL + NL +
"Already configured: " + (all || "nothing yet"),
}
}
// ---- remove ----
if (args.remove === true) {
// A proxy can also be declared on the provider itself. That declaration takes
// precedence over this mapping and the tool cannot remove it, so report it rather
// than claiming nothing is configured while traffic is plainly being proxied.
const alsoDeclared = readDeclaredProxy()
if (!mappingProxy(state.byProvider[resolvedID])) {
return {
title: alsoDeclared ? "Proxy: declared in config" : "Proxy: nothing to remove",
output: alsoDeclared
? "**" + providerLabel + "** has no proxy in the mapping file, but `opencode.jsonc` " +
"declares one that takes precedence:" + NL + NL +
"```" + NL + "\"options\": { \"proxy\": \"" + redactProxy(alsoDeclared) + "\" }" + NL + "```" + NL + NL +
"Remove that line to stop proxying. This command will not edit opencode.jsonc, " +
"because the file is JSONC and rewriting it would discard your comments." +
(all ? NL + NL + "Mapping file also has: " + all : "")
: "**" + providerLabel + "** has no proxy set, so there is nothing to undo. " +
"It is already connecting directly, which is honestly the simpler life." +
NL + NL + "Configured: " + (all || "nothing yet"),
}
}
delete state.byProvider[resolvedID]
persist()
const left = Object.keys(state.byProvider)
return {
title: "Proxy removed",
output:
"**Proxy removed** for **" + providerLabel + "**. It goes direct again, and " +
"that is saved." + NL + NL +
"Remaining: " + (left.length ? left.join(", ") : "nothing") +
". Set it again any time with `/proxy`.",
}
}
// ---- set ----
const proxy = str(args.proxy)
if (!proxy) {
return {
title: "Proxy: needs a URL",
output:
"I need a proxy URL to work with." + NL + NL +
"```" + NL +
"/proxy <proxyURL> configure the current provider" + NL +
"/proxy <resolvedID> <proxyURL>" + NL +
"/proxy remove undo it" + NL +
"```" + NL + NL +
"Example: `/proxy http://192.168.10.101:3128`" +
(all ? NL + NL + "Already configured: " + all : ""),
}
}
let parsed
try {
parsed = new URL(proxy)
} catch {
return {
title: "Proxy: bad URL",
output:
"`" + proxy + "` is not a URL I can use. It needs a scheme and a port, for " +
"example `http://192.168.10.101:3128`.",
}
}
if (malformedCredentials(proxy)) {
return {
title: "Proxy: credentials not usable",
output:
"`" + redactProxy(proxy) + "` has credentials in a form the URL parser drops, " +
"so the proxy would be contacted with no authentication at all." + NL + NL +
"This usually means a backslash in the username (for example `DOMAIN\\\\user`). " +
"Percent-encode it as `%5C`, and percent-encode any special characters in the " +
"password too:" + NL + NL +
"```" + NL +
"/proxy http://DOMAIN%5Cuser:p%[email protected]:3128" + NL +
"```",
}
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return {
title: "Proxy: bad protocol",
output:
"`" + parsed.protocol + "` is not something I can tunnel through. Use " +
"`http://` or `https://`.",
}
}
state.byProvider[resolvedID] = model ? { proxy, model } : { proxy }
persist()
// Probe once, bounded, so the user learns immediately whether the proxy actually
// serves this provider rather than finding out on the next prompt. Cached per
// provider+proxy, and never allowed to fail the write.
let probeLine = ""
try {
const cacheKey = resolvedID + "|" + proxy
if (!state.probe.has(cacheKey)) {
let outcome
try {
// Route the probe THROUGH the proxy. Fetching the probe host directly would
// succeed no matter how broken the proxy is, which makes the check worthless.
// A temporary rule is installed for the probe host and removed in the finally,
// so the real routing table is left exactly as it was.
const probeHost = "example.com:443"
const live = getRegistry()
const savedExact = new Map(live.exact)
const savedHosts = new Map(live.hosts)
let res
try {
live.exact.set(probeHost, proxy)
res = await fetch("https://example.com/", {
method: "GET",
signal: AbortSignal.timeout(3000),
})
} finally {
live.exact.clear()
for (const [k, v] of savedExact) live.exact.set(k, v)
live.hosts.clear()
for (const [k, v] of savedHosts) live.hosts.set(k, v)
}
outcome = res.status < 500 ? "reachable" : "answered HTTP " + res.status
} catch (err) {
const msg = String(err && err.message ? err.message : err)
outcome = /timeout|abort/i.test(msg)
? "not responding (timed out)"
: "unreachable (" + msg + ")"
}
state.probe.set(cacheKey, outcome)
}
probeLine = NL + "- **Check:** " + state.probe.get(cacheKey) + "."
} catch {
probeLine = ""
}
const others = Object.keys(state.byProvider).filter((k) => k !== resolvedID)
return {
title: "Proxy set",
output:
"**Done.** `" + redactProxy(proxy) + "` now carries traffic for **" + providerLabel + "**." + NL + NL +
"- **Scope:** this provider only. Your other traffic stays direct and never " +
"touches the corporate proxy." + NL +
"- **Sticky:** saved to `proxy-providers.json`, so it survives a restart." + NL +
"- **Undo:** `/proxy remove`." +
probeLine +
(others.length ? NL + NL + "Also configured: " + others.join(", ") : ""),
}
}
if (toolHelper) {
const { tool, z } = toolHelper
return tool({
description:
"Set or remove the HTTP proxy used for one OpenCode provider. Use when a provider " +
"must be reached through a proxy, e.g. a LAN model server behind a corporate proxy. " +
"Applies to that provider ONLY; every other provider keeps connecting directly." +
"\n\nArguments come from the /proxy command and may be: a bare proxy URL " +
"(configure the provider this session is already using -- omit resolvedID and the " +
"tool resolves it); a provider id followed by a URL; or the word remove. Call this " +
"tool exactly once, then reply with its output verbatim and add nothing of your own." +
"\n\nWith no proxy and no remove flag it returns usage help, which is the correct " +
"response to a bare /proxy.",
args: {
resolvedID: z
.string()
.optional()
.describe("Which provider to configure. Omit to use this session provider."),
proxy: z.string().optional().describe("Proxy URL, e.g. http://192.168.10.101:3128"),
remove: z.boolean().optional().describe("Remove the proxy for this provider."),
},
execute: run,
})
}
return {
description:
"Set or remove the HTTP proxy for one OpenCode provider. Applies to that provider " +
"only. Arguments: a bare proxy URL, a provider id plus a URL, or remove. Call once " +
"and reply with the output verbatim.",
args: PROXY_TOOL_INPUT,
execute: run,
}
}
/* ------------------------------------------------------------------ plugin */
export const FetchProxyPlugin = async ({ client } = {}) => {
// NOTE: do NOT return early when another instance already patched fetch. OpenCode
// instantiates this plugin more than once; each instance must still contribute its
// config/tool hooks. Only the global fetch patch has to be installed once.
const fetchAlreadyPatched = Boolean(globalThis[MARKER])
const baseFetch = globalThis.fetch
if (typeof baseFetch !== "function") {
console.error("[fetch-proxy] global fetch unavailable; plugin inactive")
return {}
}
// Ship the slash command as the file this build actually reads. The config-map
// injection below is kept as a second path, but the file is the supported one.
const cmdFile = ensureProxyCommandFile()
const proxyProvidersFile = proxyProvidersPath()
const proxyState = { byProvider: readProxyProviders(proxyProvidersFile), currentProviderID: null, currentModelName: "", currentModelID: "", probe: new Map() }
let byProvider = proxyState.byProvider
// Loaded dynamically so the file needs no hard dependency. Without it the /proxy
// command still works; only the tool the agent calls is skipped.
let toolHelper = null
try {
const sdk = await import("@opencode-ai/plugin")
const t = sdk?.tool
const z = sdk?.tool?.schema ?? sdk?.z
if (typeof t === "function" && z) toolHelper = { tool: t, z }
} catch {
/* command-only mode */
}
const fileCfg = loadFileConfig()
const envTargets = process.env.OPENCODE_PROXY_TARGETS
? process.env.OPENCODE_PROXY_TARGETS.split(",").map(str).filter(Boolean)
: null
const globalCfg = {
proxy: str(process.env.OPENCODE_PROXY_URL) || fileCfg.proxy,
targets: envTargets ?? fileCfg.targets,
routeAllProviders:
process.env.OPENCODE_PROXY_ALL === "1" || fileCfg.routeAllProviders === true,
log: process.env.OPENCODE_PROXY_LOG === "1" || fileCfg.log,
}
let lastProviders = null
let inline = []
let registry = buildRegistry(globalCfg, inline, byProvider)
/** Signature of the mapping file the last time rules were built, for change detection. */
let mappingStamp = ""
/**