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 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