opencode-codex-websearch 0.1.0
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/LICENSE +21 -0
- package/README.md +50 -0
- package/package.json +45 -0
- package/src/codex-web-search.ts +437 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ysm-dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# opencode-codex-websearch
|
|
2
|
+
|
|
3
|
+
An [OpenCode](https://opencode.ai) plugin that adds a `codex_web_search` tool backed by ChatGPT's Codex search endpoint. It uses the ChatGPT OAuth credentials already managed by OpenCode, so no separate search API key is required.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- OpenCode with ChatGPT OAuth authentication
|
|
8
|
+
- A ChatGPT account with Codex access
|
|
9
|
+
|
|
10
|
+
Run `opencode auth login` if OpenCode is not already authenticated with ChatGPT.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Add the package to your OpenCode configuration:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"$schema": "https://opencode.ai/config.json",
|
|
19
|
+
"plugin": ["opencode-codex-websearch"]
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Quit and restart OpenCode after changing the configuration.
|
|
24
|
+
|
|
25
|
+
## Tool
|
|
26
|
+
|
|
27
|
+
The plugin registers `codex_web_search` with these arguments:
|
|
28
|
+
|
|
29
|
+
| Argument | Required | Description |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| `query` | Yes | Web search query |
|
|
32
|
+
| `max_results` | No | Maximum results, from 1 to 20 (default: 8) |
|
|
33
|
+
| `recency` | No | Only return results from the last N days |
|
|
34
|
+
| `domains` | No | Only return results from the specified domains |
|
|
35
|
+
|
|
36
|
+
Results contain only each page's title, URL, and snippet, formatted as Markdown.
|
|
37
|
+
|
|
38
|
+
## Authentication
|
|
39
|
+
|
|
40
|
+
The plugin reads OpenCode's `openai` OAuth entry from `OPENCODE_AUTH_CONTENT` or OpenCode's local `auth.json`. Credentials are sent only to ChatGPT's Codex endpoints.
|
|
41
|
+
|
|
42
|
+
This plugin uses an internal ChatGPT endpoint that may change without notice.
|
|
43
|
+
|
|
44
|
+
## Releasing
|
|
45
|
+
|
|
46
|
+
Maintainers publish by updating `package.json`, merging the change to `main`, and publishing a GitHub Release whose tag is `v` followed by the package version. The release workflow publishes to npm through OIDC without an npm token and npm automatically records provenance.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
[MIT](LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-codex-websearch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin for web search using ChatGPT Codex authentication",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/codex-web-search.ts",
|
|
7
|
+
"module": "src/codex-web-search.ts",
|
|
8
|
+
"types": "src/codex-web-search.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/codex-web-search.ts"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"prepublishOnly": "npm run typecheck"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"opencode",
|
|
21
|
+
"opencode-plugin",
|
|
22
|
+
"codex",
|
|
23
|
+
"chatgpt",
|
|
24
|
+
"web-search"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/ysm-dev/opencode-codex-websearch.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/ysm-dev/opencode-codex-websearch/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/ysm-dev/opencode-codex-websearch#readme",
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@opencode-ai/plugin": "^1.18.26"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/bun": "latest",
|
|
43
|
+
"typescript": "^7.0.2"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import { existsSync } from "node:fs"
|
|
3
|
+
import { readFile, realpath } from "node:fs/promises"
|
|
4
|
+
import { homedir } from "node:os"
|
|
5
|
+
import { dirname, join, resolve } from "node:path"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
7
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
8
|
+
import { tool } from "@opencode-ai/plugin/tool"
|
|
9
|
+
|
|
10
|
+
const SEARCH_ENDPOINT = "https://chatgpt.com/backend-api/codex/alpha/search"
|
|
11
|
+
const MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models?client_version=0.147.0"
|
|
12
|
+
const REQUEST_TIMEOUT_MS = 15_000
|
|
13
|
+
const DEFAULT_MAX_RESULTS = 8
|
|
14
|
+
const MAX_OUTPUT_BYTES = 20_000
|
|
15
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
16
|
+
|
|
17
|
+
type CodexAuth = {
|
|
18
|
+
accessToken: string
|
|
19
|
+
accountId?: string
|
|
20
|
+
fedramp: boolean
|
|
21
|
+
uploaded: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type OpenCodeOAuth = {
|
|
25
|
+
type: "oauth"
|
|
26
|
+
access: string
|
|
27
|
+
expires: number
|
|
28
|
+
accountId?: string
|
|
29
|
+
fedramp?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type SearchResult = {
|
|
33
|
+
title: string
|
|
34
|
+
url: string
|
|
35
|
+
snippet?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const registeredClients = new WeakSet()
|
|
39
|
+
let codexModel: string | undefined
|
|
40
|
+
|
|
41
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return typeof value === "object" && value !== null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function errorCode(error: unknown) {
|
|
46
|
+
return isRecord(error) && typeof error.code === "string" ? error.code : undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cleanText(value: unknown, maxBytes: number): string | undefined {
|
|
50
|
+
if (typeof value !== "string") return undefined
|
|
51
|
+
|
|
52
|
+
const text = value.replace(/\s+/g, " ").trim()
|
|
53
|
+
if (!text) return undefined
|
|
54
|
+
return truncateText(text, maxBytes)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function truncateText(value: string, maxBytes: number) {
|
|
58
|
+
if (Buffer.byteLength(value, "utf8") <= maxBytes) return value
|
|
59
|
+
|
|
60
|
+
const segments: string[] = []
|
|
61
|
+
let size = Buffer.byteLength("...", "utf8")
|
|
62
|
+
for (const item of GRAPHEME_SEGMENTER.segment(value)) {
|
|
63
|
+
const next = Buffer.byteLength(item.segment, "utf8")
|
|
64
|
+
if (size + next > maxBytes) break
|
|
65
|
+
segments.push(item.segment)
|
|
66
|
+
size += next
|
|
67
|
+
}
|
|
68
|
+
return `${segments.join("")}...`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeUrl(value: unknown): string | undefined {
|
|
72
|
+
if (typeof value !== "string" || value.length > 2_048) return undefined
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const url = new URL(value)
|
|
76
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return undefined
|
|
77
|
+
const normalized = url.toString()
|
|
78
|
+
return Buffer.byteLength(normalized, "utf8") <= 2_048 ? normalized : undefined
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeResponse(payload: unknown, limit: number) {
|
|
85
|
+
if (
|
|
86
|
+
!isRecord(payload) ||
|
|
87
|
+
typeof payload.output !== "string" ||
|
|
88
|
+
(payload.results !== undefined && payload.results !== null && !Array.isArray(payload.results))
|
|
89
|
+
) {
|
|
90
|
+
throw new Error("Codex web search returned an invalid response")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const results: SearchResult[] = []
|
|
94
|
+
for (const item of payload.results ?? []) {
|
|
95
|
+
if (!isRecord(item)) continue
|
|
96
|
+
|
|
97
|
+
const url = normalizeUrl(item.url)
|
|
98
|
+
if (!url) continue
|
|
99
|
+
|
|
100
|
+
results.push({
|
|
101
|
+
title: cleanText(item.title, 300) ?? url,
|
|
102
|
+
url,
|
|
103
|
+
snippet: cleanText(item.snippet, 1_000),
|
|
104
|
+
})
|
|
105
|
+
if (results.length >= limit) break
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return results
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatResponse(query: string, results: SearchResult[]): string {
|
|
112
|
+
const items: string[] = []
|
|
113
|
+
for (const result of results) {
|
|
114
|
+
const snippet = result.snippet ? `\n${result.snippet}` : ""
|
|
115
|
+
const item = `## ${result.title}\n${result.url}${snippet}`
|
|
116
|
+
if (items.length && Buffer.byteLength([...items, item].join("\n\n"), "utf8") > MAX_OUTPUT_BYTES) break
|
|
117
|
+
items.push(item)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (items.length === 0) return `No web search results found for: "${query}".`
|
|
121
|
+
return truncateText(items.join("\n\n"), MAX_OUTPUT_BYTES)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function loadCodexAuth() {
|
|
125
|
+
const environment = openCodeAuthEnvironment()
|
|
126
|
+
const auth = parseOpenCodeOAuth((environment ?? (await loadOpenCodeAuthFile())).openai)
|
|
127
|
+
if (!auth) {
|
|
128
|
+
throw new Error("OpenCode ChatGPT authentication is unavailable; run `opencode auth login` and retry")
|
|
129
|
+
}
|
|
130
|
+
if (auth.expires <= Date.now() + REQUEST_TIMEOUT_MS) {
|
|
131
|
+
if (environment) {
|
|
132
|
+
throw new Error("Uploaded OpenCode ChatGPT authentication has expired; recreate the workspace and retry")
|
|
133
|
+
}
|
|
134
|
+
throw new Error("OpenCode ChatGPT authentication has expired; run `opencode auth login` and retry")
|
|
135
|
+
}
|
|
136
|
+
return codexAuth(auth, environment !== undefined)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseOpenCodeOAuth(auth: unknown): OpenCodeOAuth | undefined {
|
|
140
|
+
if (!isRecord(auth) || auth.type !== "oauth" || typeof auth.access !== "string" || typeof auth.expires !== "number") {
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
type: "oauth",
|
|
145
|
+
access: auth.access,
|
|
146
|
+
expires: auth.expires,
|
|
147
|
+
...(typeof auth.accountId === "string" ? { accountId: auth.accountId } : {}),
|
|
148
|
+
...(typeof auth.fedramp === "boolean" ? { fedramp: auth.fedramp } : {}),
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function loadOpenCodeAuthFile() {
|
|
153
|
+
const parsed = parseOpenCodeAuth(await readFile(openCodeAuthPath(), "utf8"))
|
|
154
|
+
if (parsed) return parsed
|
|
155
|
+
throw new Error("OpenCode authentication data is invalid")
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function openCodeAuthEnvironment() {
|
|
159
|
+
const environment = process.env.OPENCODE_AUTH_CONTENT?.trim()
|
|
160
|
+
return environment ? parseOpenCodeAuth(environment) : undefined
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseOpenCodeAuth(content: string): Record<string, unknown> | undefined {
|
|
164
|
+
try {
|
|
165
|
+
const parsed: unknown = JSON.parse(content)
|
|
166
|
+
return isRecord(parsed) ? parsed : undefined
|
|
167
|
+
} catch {
|
|
168
|
+
return undefined
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function openCodeAuthPath() {
|
|
173
|
+
const dataRoot = process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share")
|
|
174
|
+
return join(dataRoot, "opencode", "auth.json")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function openCodeConfigPaths() {
|
|
178
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config")
|
|
179
|
+
const custom = process.env.OPENCODE_CONFIG_DIR?.trim()
|
|
180
|
+
return [
|
|
181
|
+
resolve(join(xdgConfig, "opencode")),
|
|
182
|
+
resolve(join(homedir(), ".opencode")),
|
|
183
|
+
...(custom ? [resolve(custom)] : []),
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function projectConfigPaths(directory: string, worktree: string): string[] {
|
|
188
|
+
const root = resolve(worktree)
|
|
189
|
+
const current = resolve(directory)
|
|
190
|
+
if (current === root) return [resolve(root, ".opencode")]
|
|
191
|
+
const parent = dirname(current)
|
|
192
|
+
if (parent === current) return [resolve(current, ".opencode")]
|
|
193
|
+
return [resolve(current, ".opencode"), ...projectConfigPaths(parent, root)]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function canonicalPath(path: string) {
|
|
197
|
+
return realpath(path).catch((error: unknown) => {
|
|
198
|
+
if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return resolve(path)
|
|
199
|
+
throw error
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function codexAuth(auth: OpenCodeOAuth, uploaded: boolean): CodexAuth {
|
|
204
|
+
const claims = tokenAuthClaims(auth.access)
|
|
205
|
+
return {
|
|
206
|
+
accessToken: auth.access,
|
|
207
|
+
accountId: cleanText(auth.accountId, 1_000) ?? claims.accountId,
|
|
208
|
+
fedramp: auth.fedramp ?? claims.fedramp,
|
|
209
|
+
uploaded,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function authHeaders(auth: CodexAuth) {
|
|
214
|
+
const headers: Record<string, string> = {
|
|
215
|
+
Accept: "application/json",
|
|
216
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
217
|
+
"User-Agent": "codex-cli/0.147.0-alpha.6.5",
|
|
218
|
+
}
|
|
219
|
+
if (auth.accountId) headers["ChatGPT-Account-ID"] = auth.accountId
|
|
220
|
+
if (auth.fedramp) headers["X-OpenAI-Fedramp"] = "true"
|
|
221
|
+
return headers
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function requireSuccessfulResponse(response: Response, auth: CodexAuth, operation: string) {
|
|
225
|
+
if (response.status === 401) {
|
|
226
|
+
if (auth.uploaded) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"Uploaded OpenCode ChatGPT authentication was rejected or expired; recreate the workspace with current credentials",
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
throw new Error("OpenCode ChatGPT authentication was rejected or expired; run `opencode auth login` and retry")
|
|
232
|
+
}
|
|
233
|
+
if (response.status === 403 && response.headers.get("cf-mitigated")?.toLowerCase() === "challenge") {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`${operation} was blocked by a Cloudflare browser challenge; retry later or from a different network`,
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
if (response.status === 403) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`${operation} is forbidden for the current ChatGPT account, model, or workspace; verify that this account has Codex access`,
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
if (response.status === 429) throw new Error(`${operation} rate limit exceeded; retry later`)
|
|
244
|
+
if (!response.ok) throw new Error(`${operation} failed with HTTP ${response.status}`)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function loadCodexModel(auth: CodexAuth, signal: AbortSignal) {
|
|
248
|
+
if (codexModel) return codexModel
|
|
249
|
+
codexModel = await discoverCodexModel(auth, signal)
|
|
250
|
+
return codexModel
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function discoverCodexModel(auth: CodexAuth, signal: AbortSignal) {
|
|
254
|
+
const response = await fetch(MODELS_ENDPOINT, { headers: authHeaders(auth), signal }).catch((error: unknown) => {
|
|
255
|
+
const message = error instanceof Error ? error.message : "unknown network error"
|
|
256
|
+
throw new Error(`Codex model discovery request failed: ${message}`, { cause: error })
|
|
257
|
+
})
|
|
258
|
+
requireSuccessfulResponse(response, auth, "Codex model discovery")
|
|
259
|
+
|
|
260
|
+
const payload: unknown = await response.json().catch((error: unknown) => {
|
|
261
|
+
throw new Error("Codex model discovery returned invalid JSON", { cause: error })
|
|
262
|
+
})
|
|
263
|
+
if (!isRecord(payload) || !Array.isArray(payload.models)) {
|
|
264
|
+
throw new Error("Codex model discovery returned an invalid response")
|
|
265
|
+
}
|
|
266
|
+
const candidates = payload.models
|
|
267
|
+
.flatMap((model) => {
|
|
268
|
+
if (
|
|
269
|
+
!isRecord(model) ||
|
|
270
|
+
typeof model.slug !== "string" ||
|
|
271
|
+
!model.slug ||
|
|
272
|
+
typeof model.priority !== "number" ||
|
|
273
|
+
!Number.isFinite(model.priority) ||
|
|
274
|
+
!["list", "hide", "none"].includes(String(model.visibility))
|
|
275
|
+
) {
|
|
276
|
+
return []
|
|
277
|
+
}
|
|
278
|
+
return [{ slug: model.slug, priority: model.priority, visibility: String(model.visibility) }]
|
|
279
|
+
})
|
|
280
|
+
.sort((left, right) => left.priority - right.priority)
|
|
281
|
+
const model = candidates.find((candidate) => candidate.visibility === "list") ?? candidates[0]
|
|
282
|
+
if (!model) throw new Error("Could not determine an account-eligible Codex model for web search; retry later")
|
|
283
|
+
return model.slug
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function tokenAuthClaims(token: string) {
|
|
287
|
+
const claims = parseJwtClaims(token)
|
|
288
|
+
const nestedAuth = claims?.["https://api.openai.com/auth"]
|
|
289
|
+
const nested = isRecord(nestedAuth) ? nestedAuth : undefined
|
|
290
|
+
const organizations = Array.isArray(claims?.organizations) ? claims.organizations : []
|
|
291
|
+
const organization = organizations.find(isRecord)
|
|
292
|
+
return {
|
|
293
|
+
accountId:
|
|
294
|
+
cleanText(claims?.chatgpt_account_id, 1_000) ??
|
|
295
|
+
cleanText(nested?.chatgpt_account_id, 1_000) ??
|
|
296
|
+
cleanText(organization?.id, 1_000),
|
|
297
|
+
fedramp:
|
|
298
|
+
typeof claims?.chatgpt_account_is_fedramp === "boolean"
|
|
299
|
+
? claims.chatgpt_account_is_fedramp
|
|
300
|
+
: nested?.chatgpt_account_is_fedramp === true,
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseJwtClaims(token: string): Record<string, unknown> | undefined {
|
|
305
|
+
const payload = token.split(".")[1]
|
|
306
|
+
if (!payload) return undefined
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const parsed: unknown = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
|
|
310
|
+
return isRecord(parsed) ? parsed : undefined
|
|
311
|
+
} catch {
|
|
312
|
+
return undefined
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export const CodexWebSearchPlugin: Plugin = async ({ client, directory, worktree }) => {
|
|
317
|
+
const currentPath = await canonicalPath(fileURLToPath(import.meta.url))
|
|
318
|
+
const projectConfigDisabled = ["1", "true"].includes(process.env.OPENCODE_DISABLE_PROJECT_CONFIG?.toLowerCase() ?? "")
|
|
319
|
+
const projectRoots = projectConfigDisabled
|
|
320
|
+
? []
|
|
321
|
+
: await Promise.all(projectConfigPaths(directory, worktree).map(canonicalPath))
|
|
322
|
+
const projectPaths = await Promise.all(
|
|
323
|
+
projectRoots.flatMap((root) =>
|
|
324
|
+
["plugin", "plugins"].map((pluginDirectory) =>
|
|
325
|
+
canonicalPath(resolve(root, pluginDirectory, "codex-web-search.ts")),
|
|
326
|
+
),
|
|
327
|
+
),
|
|
328
|
+
)
|
|
329
|
+
const globalPaths = await Promise.all(
|
|
330
|
+
openCodeConfigPaths().flatMap((root) =>
|
|
331
|
+
["plugin", "plugins"].map((pluginDirectory) => canonicalPath(join(root, pluginDirectory, "codex-web-search.ts"))),
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
const selectedProjectPath = projectPaths.find((path) => existsSync(path))
|
|
335
|
+
const selectedGlobalPath = globalPaths.findLast((path) => existsSync(path))
|
|
336
|
+
if (selectedProjectPath && selectedProjectPath !== currentPath) return {}
|
|
337
|
+
if (!selectedProjectPath && globalPaths.includes(currentPath) && selectedGlobalPath !== currentPath) return {}
|
|
338
|
+
if (registeredClients.has(client)) return {}
|
|
339
|
+
registeredClients.add(client)
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
tool: {
|
|
343
|
+
codex_web_search: tool({
|
|
344
|
+
description: "Search the web",
|
|
345
|
+
args: {
|
|
346
|
+
query: tool.schema.string().trim().min(1).max(500).describe("The web search query"),
|
|
347
|
+
max_results: tool.schema
|
|
348
|
+
.number()
|
|
349
|
+
.int()
|
|
350
|
+
.min(1)
|
|
351
|
+
.max(20)
|
|
352
|
+
.optional()
|
|
353
|
+
.describe("Maximum number of structured results to return (default: 8)"),
|
|
354
|
+
recency: tool.schema
|
|
355
|
+
.number()
|
|
356
|
+
.int()
|
|
357
|
+
.min(1)
|
|
358
|
+
.max(3_650)
|
|
359
|
+
.optional()
|
|
360
|
+
.describe("Only return results from the last N days"),
|
|
361
|
+
domains: tool.schema
|
|
362
|
+
.array(tool.schema.string().trim().min(1).max(253))
|
|
363
|
+
.max(20)
|
|
364
|
+
.optional()
|
|
365
|
+
.describe("Only return results from these domains, such as github.com"),
|
|
366
|
+
},
|
|
367
|
+
async execute({ query, max_results, recency, domains }, context) {
|
|
368
|
+
const maxResults = max_results ?? DEFAULT_MAX_RESULTS
|
|
369
|
+
const searchQuery: { q: string; recency?: number; domains?: string[] } = { q: query }
|
|
370
|
+
if (recency !== undefined) searchQuery.recency = recency
|
|
371
|
+
if (domains?.length) searchQuery.domains = domains
|
|
372
|
+
|
|
373
|
+
context.metadata({ title: `Web search: ${query}` })
|
|
374
|
+
await context.ask({
|
|
375
|
+
permission: "codex_web_search",
|
|
376
|
+
patterns: [query],
|
|
377
|
+
always: ["*"],
|
|
378
|
+
metadata: { query, max_results, recency, domains, provider: "codex-standalone-search" },
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const controller = new AbortController()
|
|
382
|
+
let timedOut = false
|
|
383
|
+
const cancelRequest = () => controller.abort()
|
|
384
|
+
if (context.abort.aborted) cancelRequest()
|
|
385
|
+
else context.abort.addEventListener("abort", cancelRequest, { once: true })
|
|
386
|
+
const timeout = setTimeout(() => {
|
|
387
|
+
timedOut = true
|
|
388
|
+
controller.abort()
|
|
389
|
+
}, REQUEST_TIMEOUT_MS)
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
const auth = await loadCodexAuth()
|
|
393
|
+
const model = await loadCodexModel(auth, controller.signal)
|
|
394
|
+
const headers = { ...authHeaders(auth), "Content-Type": "application/json" }
|
|
395
|
+
|
|
396
|
+
const response = await fetch(SEARCH_ENDPOINT, {
|
|
397
|
+
method: "POST",
|
|
398
|
+
headers,
|
|
399
|
+
body: JSON.stringify({
|
|
400
|
+
id: `search_session_${randomUUID().replaceAll("-", "").slice(0, 16)}`,
|
|
401
|
+
model,
|
|
402
|
+
commands: { search_query: [searchQuery] },
|
|
403
|
+
}),
|
|
404
|
+
signal: controller.signal,
|
|
405
|
+
}).catch((error: unknown) => {
|
|
406
|
+
const message = error instanceof Error ? error.message : "unknown network error"
|
|
407
|
+
throw new Error(`Codex web search request failed: ${message}`, { cause: error })
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
requireSuccessfulResponse(response, auth, "Codex web search")
|
|
411
|
+
|
|
412
|
+
const payload: unknown = await response.json().catch((error: unknown) => {
|
|
413
|
+
throw new Error("Codex web search returned invalid JSON", { cause: error })
|
|
414
|
+
})
|
|
415
|
+
const results = normalizeResponse(payload, maxResults)
|
|
416
|
+
return {
|
|
417
|
+
title: `Web search: ${query}`,
|
|
418
|
+
output: formatResponse(query, results),
|
|
419
|
+
metadata: { query, resultCount: results.length },
|
|
420
|
+
}
|
|
421
|
+
} catch (error) {
|
|
422
|
+
if (timedOut) {
|
|
423
|
+
throw new Error(`Codex web search timed out after ${REQUEST_TIMEOUT_MS}ms`, { cause: error })
|
|
424
|
+
}
|
|
425
|
+
if (context.abort.aborted) throw new Error("Codex web search was cancelled", { cause: error })
|
|
426
|
+
throw error
|
|
427
|
+
} finally {
|
|
428
|
+
clearTimeout(timeout)
|
|
429
|
+
context.abort.removeEventListener("abort", cancelRequest)
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
}),
|
|
433
|
+
},
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export default CodexWebSearchPlugin
|