model-compare-opencode-plugin 0.0.0-stage → 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 +674 -0
- package/README.md +182 -2
- package/package.json +55 -4
- package/src/config.ts +197 -0
- package/src/core/catalog.ts +189 -0
- package/src/core/hash.ts +12 -0
- package/src/core/pins.ts +158 -0
- package/src/core/rank.ts +132 -0
- package/src/core/types.ts +50 -0
- package/src/index.ts +478 -0
- package/src/opencode/aliases.ts +58 -0
- package/src/opencode/commands.ts +251 -0
- package/src/opencode/guard.ts +44 -0
- package/src/opencode/refresh.ts +188 -0
- package/src/opencode/resolution.ts +69 -0
- package/src/opencode/storage.ts +97 -0
- package/src/opencode/usebest.ts +121 -0
- package/src/rpc.ts +78 -0
- package/src/sources/index.ts +115 -0
- package/src/sources/local.ts +175 -0
- package/src/sources/remote.ts +155 -0
- package/src/tui.tsx +772 -0
- package/vendor/UPSTREAM.json +6 -0
- package/vendor/model_compare.py +1950 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// src/opencode/usebest.ts
|
|
2
|
+
// Shared /use-best decision + alias-view helpers (design §10). Pure module:
|
|
3
|
+
// no OpenCode imports, no I/O. Both the server command (src/index.ts) and the
|
|
4
|
+
// TUI entry (src/tui.tsx) consume it, so their decisions and messages can
|
|
5
|
+
// never drift apart.
|
|
6
|
+
|
|
7
|
+
import { parseUseBest, sanitizeCatalogText } from "./commands"
|
|
8
|
+
import type { Priority } from "../core/types"
|
|
9
|
+
|
|
10
|
+
export const PRIORITIES: readonly Priority[] = ["balanced", "price", "quality"]
|
|
11
|
+
export const ALIAS_PROVIDER = "openrouter"
|
|
12
|
+
export const aliasId = (p: Priority): string => `model-compare/${p}`
|
|
13
|
+
export const aliasRef = (p: Priority): string => `${ALIAS_PROVIDER}/${aliasId(p)}`
|
|
14
|
+
|
|
15
|
+
// The headless refusal text (§10; pinned by the integration suite's
|
|
16
|
+
// REFUSAL_TEXT).
|
|
17
|
+
export function useBestRefusal(p: Priority): string {
|
|
18
|
+
return `model-compare: ${aliasRef(p)} is currently disabled (no ranking data); not switching`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The switch notice: the pick shown is the one the alias serves right now (a
|
|
22
|
+
// pin can differ from rank 1 — minLead hysteresis), sanitized (F-20); "null"
|
|
23
|
+
// means the caller could not observe a pick and says "unavailable".
|
|
24
|
+
export function useBestNotice(p: Priority, currentPickId: string | null): string {
|
|
25
|
+
const pick = currentPickId === null ? "unavailable" : sanitizeCatalogText(currentPickId)
|
|
26
|
+
return `model-compare: switched to ${aliasRef(p)} (currently ${pick})`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The full decision for a /use-best invocation: argument parsing plus the
|
|
30
|
+
// enabled gate. `enabled` is the caller's registry view of each priority —
|
|
31
|
+
// the server command reads state.enabled, the TUI entry reads the model
|
|
32
|
+
// registry (which omits disabled aliases in some states and reports
|
|
33
|
+
// `enabled: false` in others; absence is read as disabled by the caller).
|
|
34
|
+
export function useBestDecision(input: {
|
|
35
|
+
argument: string
|
|
36
|
+
defaultPriority: Priority
|
|
37
|
+
enabled: (p: Priority) => boolean
|
|
38
|
+
}): { ok: true; priority: Priority } | { ok: false; message: string } {
|
|
39
|
+
const parsed = parseUseBest(input.argument, input.defaultPriority)
|
|
40
|
+
if (!parsed.ok) return parsed
|
|
41
|
+
if (!input.enabled(parsed.priority)) {
|
|
42
|
+
return { ok: false, message: useBestRefusal(parsed.priority) }
|
|
43
|
+
}
|
|
44
|
+
return parsed
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- registry views (the TUI side's data source) ---------------------------
|
|
48
|
+
|
|
49
|
+
// One model-compare alias as observed in the model registry.
|
|
50
|
+
export type AliasView = {
|
|
51
|
+
priority: Priority
|
|
52
|
+
name: string
|
|
53
|
+
enabled: boolean
|
|
54
|
+
modelID: string | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Extract the plugin's alias entries from a model-list response. Only entries
|
|
58
|
+
// present in the list are returned; a disabled alias is either omitted
|
|
59
|
+
// entirely or reported with enabled: false — callers must treat a missing
|
|
60
|
+
// view as disabled (probe-verified: list() omits disabled aliases).
|
|
61
|
+
export function readAliasViews(
|
|
62
|
+
entries: ReadonlyArray<{ id?: unknown; name?: unknown; enabled?: unknown; modelID?: unknown }>,
|
|
63
|
+
): AliasView[] {
|
|
64
|
+
const prefix = "model-compare/"
|
|
65
|
+
const views: AliasView[] = []
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (typeof entry.id !== "string" || !entry.id.startsWith(prefix)) continue
|
|
68
|
+
const priority = entry.id.slice(prefix.length)
|
|
69
|
+
if (!PRIORITIES.includes(priority as Priority)) continue
|
|
70
|
+
views.push({
|
|
71
|
+
priority: priority as Priority,
|
|
72
|
+
name: typeof entry.name === "string" ? entry.name : "",
|
|
73
|
+
enabled: entry.enabled !== false,
|
|
74
|
+
modelID: typeof entry.modelID === "string" ? entry.modelID : null,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
return views.sort(
|
|
78
|
+
(x, y) => PRIORITIES.indexOf(x.priority) - PRIORITIES.indexOf(y.priority),
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The pick shown in an alias's display name:
|
|
83
|
+
// "Model Compare: <p> → <pick>[( fallback)]" → "<pick>[( fallback)]", or null
|
|
84
|
+
// for the "(unavailable)" placeholder names.
|
|
85
|
+
export function pickFromName(name: string): string | null {
|
|
86
|
+
const arrow = name.indexOf("→")
|
|
87
|
+
if (arrow === -1) return null
|
|
88
|
+
return name.slice(arrow + 1).trim()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The pick an ENABLED alias view serves, or null when nothing observable is
|
|
92
|
+
// served: the display name is the truth (it mirrors the picker, including
|
|
93
|
+
// the "(fallback)" marker); modelID is the fallback when the name has no
|
|
94
|
+
// arrow; an empty name is also "nothing observable". Exported for the TUI
|
|
95
|
+
// half's picks dialog (statusLine's rules, one priority at a time). The
|
|
96
|
+
// result is a DISPLAY string only (footer, dialog, toasts) and is flattened
|
|
97
|
+
// here (F-20) as defense in depth: the name is normally built sanitized by
|
|
98
|
+
// planAlias, but modelID is registry-sourced and can carry raw catalog text.
|
|
99
|
+
export function pickOf(view: AliasView): string | null {
|
|
100
|
+
const pick = pickFromName(view.name) ?? view.modelID ?? (view.name === "" ? null : "unavailable")
|
|
101
|
+
return pick === null ? null : sanitizeCatalogText(pick)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The priority column width for aligned pick rows: the longest priority
|
|
105
|
+
// name ("balanced"), so every arrow — and the pick after it — starts at the
|
|
106
|
+
// same column in the picks dialog: `price` gains three spaces before its
|
|
107
|
+
// arrow, `quality` one.
|
|
108
|
+
export function alignedPriority(priority: Priority): string {
|
|
109
|
+
return priority.padEnd(Math.max(...PRIORITIES.map((p) => p.length)))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The compact footer line — the balanced pick only, e.g.
|
|
113
|
+
// `MC: balanced → xiaomi/mimo-v2.6-flash`. The full three-priority view
|
|
114
|
+
// lives behind the picks dialog (a select over the alias views);
|
|
115
|
+
// registry-absent and disabled aliases read "disabled", a present-but-
|
|
116
|
+
// unresolvable one reads "unavailable" — the line never vanishes.
|
|
117
|
+
export function statusLine(views: AliasView[]): string {
|
|
118
|
+
const view = views.find((item) => item.priority === "balanced")
|
|
119
|
+
if (view === undefined || view.enabled === false) return "MC: balanced → disabled"
|
|
120
|
+
return `MC: balanced → ${pickOf(view) ?? "unavailable"}`
|
|
121
|
+
}
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/rpc.ts
|
|
2
|
+
// The plugin's RPC surface (V2 plugins RPC): methods the TUI half and other
|
|
3
|
+
// clients can call on the server plugin, plus the event the /model-compare
|
|
4
|
+
// command emits. The report method returns the SAME text the command
|
|
5
|
+
// synthesizes — one builder in src/index.ts serves both, so the two can
|
|
6
|
+
// never drift. The report EVENT carries the structured entries (the TUI's
|
|
7
|
+
// selectable report dialog), because the transcript notice itself only
|
|
8
|
+
// surfaces at the session's next turn.
|
|
9
|
+
import { Rpc } from "@opencode/plugin/rpc"
|
|
10
|
+
|
|
11
|
+
export const ModelCompare = Rpc.define({
|
|
12
|
+
id: "model-compare",
|
|
13
|
+
methods: {
|
|
14
|
+
report: {
|
|
15
|
+
input: { type: "object", properties: {}, additionalProperties: false },
|
|
16
|
+
output: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
text: { type: "string" },
|
|
20
|
+
age: { type: ["string", "null"] },
|
|
21
|
+
entries: {
|
|
22
|
+
type: "array",
|
|
23
|
+
items: {
|
|
24
|
+
type: "object",
|
|
25
|
+
properties: {
|
|
26
|
+
priority: { type: "string" },
|
|
27
|
+
rank: { type: ["integer", "null"] },
|
|
28
|
+
id: { type: "string" },
|
|
29
|
+
value: { type: "string" },
|
|
30
|
+
score: { type: "string" },
|
|
31
|
+
quality: { type: "string" },
|
|
32
|
+
price: { type: "string" },
|
|
33
|
+
current: { type: "boolean" },
|
|
34
|
+
fromFallback: { type: "boolean" },
|
|
35
|
+
},
|
|
36
|
+
required: ["priority", "rank", "id", "value", "score", "quality", "price", "current", "fromFallback"],
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
required: ["text", "age", "entries"],
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
events: {
|
|
47
|
+
report: {
|
|
48
|
+
schema: {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
sessionID: { type: "string" },
|
|
52
|
+
age: { type: ["string", "null"] },
|
|
53
|
+
entries: {
|
|
54
|
+
type: "array",
|
|
55
|
+
items: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {
|
|
58
|
+
priority: { type: "string" },
|
|
59
|
+
rank: { type: ["integer", "null"] },
|
|
60
|
+
id: { type: "string" },
|
|
61
|
+
value: { type: "string" },
|
|
62
|
+
score: { type: "string" },
|
|
63
|
+
quality: { type: "string" },
|
|
64
|
+
price: { type: "string" },
|
|
65
|
+
current: { type: "boolean" },
|
|
66
|
+
fromFallback: { type: "boolean" },
|
|
67
|
+
},
|
|
68
|
+
required: ["priority", "rank", "id", "value", "score", "quality", "price", "current", "fromFallback"],
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
required: ["sessionID", "age", "entries"],
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/sources/index.ts
|
|
2
|
+
// Source selection and acquisition (design §3, §9): pick the catalog source
|
|
3
|
+
// and run it. "local" runs the configured-or-vendored script and rejects on
|
|
4
|
+
// failure — the caller keeps its last-known-good document. "auto" probes
|
|
5
|
+
// python first, runs local when it passes, and degrades to the remote catalog
|
|
6
|
+
// with an actionable warning when python is unusable or the local run fails
|
|
7
|
+
// (§3: warn and degrade, never throw from setup). "remote" fetches the
|
|
8
|
+
// published catalog without ever touching python. Only a failed remote
|
|
9
|
+
// fallback rejects.
|
|
10
|
+
|
|
11
|
+
import { fileURLToPath } from "node:url"
|
|
12
|
+
import type { Config } from "../config"
|
|
13
|
+
import type { Sourced } from "../core/types"
|
|
14
|
+
import { localIdentity, remoteIdentity, type Storage } from "../opencode/storage"
|
|
15
|
+
import { probePython, runLocalScript } from "./local"
|
|
16
|
+
import { fetchRemote } from "./remote"
|
|
17
|
+
|
|
18
|
+
// The vendored script, resolved from this module (src/sources/) back to the
|
|
19
|
+
// package root so it is found regardless of the process's working directory.
|
|
20
|
+
const VENDORED_SCRIPT = fileURLToPath(new URL("../../vendor/model_compare.py", import.meta.url))
|
|
21
|
+
|
|
22
|
+
// Injectable seams for tests; each defaults to the real implementation.
|
|
23
|
+
// storage is required because the remote path cannot proceed without it.
|
|
24
|
+
export type AcquireDeps = {
|
|
25
|
+
storage: Storage
|
|
26
|
+
probePython?: typeof probePython
|
|
27
|
+
runLocal?: typeof runLocalScript
|
|
28
|
+
fetchRemote?: typeof fetchRemote
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Acquired = {
|
|
32
|
+
sourced: Sourced
|
|
33
|
+
source: "local" | "remote"
|
|
34
|
+
// The cache identity of the source that produced the document (storage.ts):
|
|
35
|
+
// the tick persists the document under it.
|
|
36
|
+
identity: string
|
|
37
|
+
fromCache: boolean
|
|
38
|
+
warnings: string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// runLocalScript prefixes every failure with this; the fallback warning
|
|
42
|
+
// re-wraps the cause instead of repeating the prefix.
|
|
43
|
+
const LOCAL_PREFIX = "local source failed: "
|
|
44
|
+
|
|
45
|
+
function asReason(error: unknown): string {
|
|
46
|
+
return error instanceof Error ? error.message : String(error)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function acquire(cfg: Config, deps: AcquireDeps): Promise<Acquired> {
|
|
50
|
+
const probe = deps.probePython ?? probePython
|
|
51
|
+
const runLocal = deps.runLocal ?? runLocalScript
|
|
52
|
+
const doFetch = deps.fetchRemote ?? fetchRemote
|
|
53
|
+
|
|
54
|
+
const scriptPath = cfg.scriptPath ?? VENDORED_SCRIPT
|
|
55
|
+
|
|
56
|
+
// Remote mode never probes python or runs the script (§9). fetchRemote's
|
|
57
|
+
// fromCache flag is lifted to the result; sourced itself stays a plain
|
|
58
|
+
// Sourced (§5).
|
|
59
|
+
if (cfg.source === "remote") {
|
|
60
|
+
const { doc, acquiredAt, fromCache } = await doFetch(cfg.catalogURL, deps.storage)
|
|
61
|
+
return {
|
|
62
|
+
sourced: { doc, acquiredAt },
|
|
63
|
+
source: "remote",
|
|
64
|
+
identity: remoteIdentity(cfg.catalogURL),
|
|
65
|
+
fromCache,
|
|
66
|
+
warnings: [],
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (cfg.source === "local") {
|
|
71
|
+
const sourced = await runLocal(scriptPath)
|
|
72
|
+
return { sourced, source: "local", identity: localIdentity(cfg.scriptPath), fromCache: false, warnings: [] }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Auto: probe first; a usable python gets one local attempt. Either
|
|
76
|
+
// failure — the probe or the run — degrades to remote with the cause named.
|
|
77
|
+
const warnings: string[] = []
|
|
78
|
+
const probed = await probe()
|
|
79
|
+
if (probed.ok) {
|
|
80
|
+
try {
|
|
81
|
+
const sourced = await runLocal(scriptPath)
|
|
82
|
+
return {
|
|
83
|
+
sourced,
|
|
84
|
+
source: "local",
|
|
85
|
+
identity: localIdentity(cfg.scriptPath),
|
|
86
|
+
fromCache: false,
|
|
87
|
+
warnings: [],
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const cause = asReason(error).replace(LOCAL_PREFIX, "")
|
|
91
|
+
warnings.push(`local source failed (${cause}); using remote catalog`)
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
warnings.push(`${probed.reason}; using remote catalog`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const { doc, acquiredAt, fromCache } = await doFetch(cfg.catalogURL, deps.storage)
|
|
98
|
+
return {
|
|
99
|
+
sourced: { doc, acquiredAt },
|
|
100
|
+
source: "remote",
|
|
101
|
+
identity: remoteIdentity(cfg.catalogURL),
|
|
102
|
+
fromCache,
|
|
103
|
+
warnings,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The cache identities a configuration can have produced a document under:
|
|
108
|
+
// setup seeds from the newest of them (under `auto` either source may have won).
|
|
109
|
+
export function cacheIdentities(cfg: Config): string[] {
|
|
110
|
+
const local = localIdentity(cfg.scriptPath)
|
|
111
|
+
const remote = remoteIdentity(cfg.catalogURL)
|
|
112
|
+
if (cfg.source === "local") return [local]
|
|
113
|
+
if (cfg.source === "remote") return [remote]
|
|
114
|
+
return [local, remote]
|
|
115
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// src/sources/local.ts
|
|
2
|
+
// The local catalog source (design §9, §11): run the vendored script with
|
|
3
|
+
// execFile — no shell, a timeout, and a minimal environment — then parse and
|
|
4
|
+
// validate whatever it printed. Any failure (missing interpreter, non-zero
|
|
5
|
+
// exit, timeout, invalid JSON, rejected document) rejects as
|
|
6
|
+
// "local source failed: <reason>"; the exit code only refines the message,
|
|
7
|
+
// with stderr informing it (§9).
|
|
8
|
+
|
|
9
|
+
import { execFile as execFileCallback } from "node:child_process"
|
|
10
|
+
import { promisify } from "node:util"
|
|
11
|
+
import { validateCatalog } from "../core/catalog"
|
|
12
|
+
import type { Sourced } from "../core/types"
|
|
13
|
+
|
|
14
|
+
const execFile = promisify(execFileCallback)
|
|
15
|
+
|
|
16
|
+
const STDERR_TAIL_CHARS = 500
|
|
17
|
+
|
|
18
|
+
// execFile's default maxBuffer is 1 MiB — easily exceeded by a catalog
|
|
19
|
+
// stdout, and the default kill would report it as a generic failure. An
|
|
20
|
+
// explicit, generous cap keeps normal runs safe while bounding memory.
|
|
21
|
+
export const MAX_BUFFER_BYTES = 64 * 1024 * 1024 // 64 MiB
|
|
22
|
+
|
|
23
|
+
// The version probe prints one line; ten seconds is generous. A hung
|
|
24
|
+
// interpreter (a stuck version-manager shim) must not stall the refresh tick.
|
|
25
|
+
const PROBE_TIMEOUT_MS = 10_000
|
|
26
|
+
|
|
27
|
+
// Environment variables the script may need beyond HOME and PATH (design
|
|
28
|
+
// §11): its cache location, the AA key, proxy settings in both casings, and
|
|
29
|
+
// custom CA paths. Passed through only when set and non-empty.
|
|
30
|
+
const PASSTHROUGH_ENV = [
|
|
31
|
+
"XDG_CACHE_HOME",
|
|
32
|
+
"AA_API_KEY",
|
|
33
|
+
"HTTP_PROXY",
|
|
34
|
+
"HTTPS_PROXY",
|
|
35
|
+
"NO_PROXY",
|
|
36
|
+
"http_proxy",
|
|
37
|
+
"https_proxy",
|
|
38
|
+
"no_proxy",
|
|
39
|
+
"SSL_CERT_FILE",
|
|
40
|
+
"SSL_CERT_DIR",
|
|
41
|
+
] as const
|
|
42
|
+
|
|
43
|
+
// The shape execFile rejects with on failure: `code` is the exit status for a
|
|
44
|
+
// non-zero exit, "ENOENT" when the interpreter is missing, and null when the
|
|
45
|
+
// child was killed by a signal; `stdout`/`stderr` carry what was collected.
|
|
46
|
+
type ExecFileError = Error & {
|
|
47
|
+
code?: string | number
|
|
48
|
+
killed?: boolean
|
|
49
|
+
signal?: NodeJS.Signals | null
|
|
50
|
+
stdout?: string
|
|
51
|
+
stderr?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The script's environment (design §11): HOME and PATH from process.env
|
|
55
|
+
// (omitted when absent) plus the passthrough list when set and non-empty.
|
|
56
|
+
// Built fresh — process.env itself is never touched.
|
|
57
|
+
export function scriptEnv(): NodeJS.ProcessEnv {
|
|
58
|
+
const env: NodeJS.ProcessEnv = {}
|
|
59
|
+
for (const key of ["HOME", "PATH"] as const) {
|
|
60
|
+
const value = process.env[key]
|
|
61
|
+
if (value !== undefined) env[key] = value
|
|
62
|
+
}
|
|
63
|
+
for (const key of PASSTHROUGH_ENV) {
|
|
64
|
+
const value = process.env[key]
|
|
65
|
+
if (value !== undefined && value !== "") env[key] = value
|
|
66
|
+
}
|
|
67
|
+
return env
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The trimmed last chunk of stderr for failure messages; empty stderr
|
|
71
|
+
// contributes nothing to the message.
|
|
72
|
+
function stderrTail(stderr: string | undefined): string {
|
|
73
|
+
const text = (stderr ?? "").trim()
|
|
74
|
+
if (text === "") return ""
|
|
75
|
+
const tail = text.length > STDERR_TAIL_CHARS ? text.slice(-STDERR_TAIL_CHARS) : text
|
|
76
|
+
return `; stderr: ${tail}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Whether the plugin can run the vendored script at all: python3 present and
|
|
80
|
+
// at least 3.10 (design §9). The reason names what failed.
|
|
81
|
+
export async function probePython(
|
|
82
|
+
timeoutMs = PROBE_TIMEOUT_MS,
|
|
83
|
+
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
84
|
+
let version: string
|
|
85
|
+
try {
|
|
86
|
+
const { stdout } = await execFile(
|
|
87
|
+
"python3",
|
|
88
|
+
["-c", "import sys; print('.'.join(map(str, sys.version_info[:2])))"],
|
|
89
|
+
{ timeout: timeoutMs, maxBuffer: MAX_BUFFER_BYTES },
|
|
90
|
+
)
|
|
91
|
+
version = stdout.trim()
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const err = error as ExecFileError
|
|
94
|
+
if (err.code === "ENOENT") {
|
|
95
|
+
return { ok: false, reason: "python3 not found (spawn ENOENT)" }
|
|
96
|
+
}
|
|
97
|
+
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
98
|
+
return { ok: false, reason: `python3 probe output exceeded the ${MAX_BUFFER_BYTES / (1024 * 1024)} MiB cap` }
|
|
99
|
+
}
|
|
100
|
+
if (err.killed && err.signal) {
|
|
101
|
+
return { ok: false, reason: `python3 probe timed out after ${timeoutMs}ms` }
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, reason: `python3 could not be probed: ${err.message}` }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const match = /^(\d+)\.(\d+)$/.exec(version)
|
|
107
|
+
if (match === null) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: `python3 reported unparseable version info: ${JSON.stringify(version)}`,
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const major = Number(match[1])
|
|
114
|
+
const minor = Number(match[2])
|
|
115
|
+
if (major < 3 || (major === 3 && minor < 10)) {
|
|
116
|
+
return { ok: false, reason: `python3 is ${major}.${minor}, need >= 3.10` }
|
|
117
|
+
}
|
|
118
|
+
return { ok: true }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Run the script (an explicit path — resolving the vendored default is the
|
|
122
|
+
// caller's job), validate its stdout, and stamp the plugin's own acquisition
|
|
123
|
+
// time on the document (design §5, §9).
|
|
124
|
+
export async function runLocalScript(
|
|
125
|
+
scriptPath: string,
|
|
126
|
+
timeoutMs = 60_000,
|
|
127
|
+
): Promise<Sourced> {
|
|
128
|
+
let stdout: string
|
|
129
|
+
try {
|
|
130
|
+
const result = await execFile("python3", [scriptPath, "--catalog"], {
|
|
131
|
+
env: scriptEnv(),
|
|
132
|
+
timeout: timeoutMs,
|
|
133
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
134
|
+
})
|
|
135
|
+
stdout = result.stdout
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const err = error as ExecFileError
|
|
138
|
+
const stderr = stderrTail(err.stderr)
|
|
139
|
+
if (err.code === "ENOENT") {
|
|
140
|
+
throw new Error(`local source failed: python3 not found (spawn ENOENT)${stderr}`)
|
|
141
|
+
}
|
|
142
|
+
// Checked before the signal branch: the maxBuffer kill also sets
|
|
143
|
+
// killed/signal, but its reason is distinct — the output cap, not the
|
|
144
|
+
// timeout.
|
|
145
|
+
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`local source failed: script output exceeded the ${MAX_BUFFER_BYTES / (1024 * 1024)} MiB cap${stderr}`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
if (err.signal) {
|
|
151
|
+
const cause = err.killed
|
|
152
|
+
? `timed out after ${timeoutMs}ms`
|
|
153
|
+
: `terminated by ${err.signal}`
|
|
154
|
+
throw new Error(`local source failed: ${cause} (killed with ${err.signal})${stderr}`)
|
|
155
|
+
}
|
|
156
|
+
if (typeof err.code === "number") {
|
|
157
|
+
throw new Error(`local source failed: exit code ${err.code}${stderr}`)
|
|
158
|
+
}
|
|
159
|
+
throw new Error(`local source failed: ${err.message}${stderr}`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let raw: unknown
|
|
163
|
+
try {
|
|
164
|
+
raw = JSON.parse(stdout)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
167
|
+
throw new Error(`local source failed: invalid JSON on stdout (${message})`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const validated = validateCatalog(raw)
|
|
171
|
+
if (!validated.ok) {
|
|
172
|
+
throw new Error(`local source failed: invalid catalog document: ${validated.reason}`)
|
|
173
|
+
}
|
|
174
|
+
return { doc: validated.catalog, acquiredAt: Date.now() }
|
|
175
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// src/sources/remote.ts
|
|
2
|
+
// The remote catalog source (design §5, §8, §9): fetch the published catalog
|
|
3
|
+
// document, validate it, and persist it as a versioned record so the last
|
|
4
|
+
// accepted document survives restarts (§9's offline path). A record younger
|
|
5
|
+
// than the TTL is served without a network round-trip; any fetch or validation
|
|
6
|
+
// failure falls back to the cached record at any age, and fetchRemote only
|
|
7
|
+
// rejects when there is no usable record at all.
|
|
8
|
+
|
|
9
|
+
import { validateCatalog } from "../core/catalog"
|
|
10
|
+
import type { Sourced } from "../core/types"
|
|
11
|
+
import {
|
|
12
|
+
readCachedDoc,
|
|
13
|
+
remoteIdentity,
|
|
14
|
+
writeCachedDoc,
|
|
15
|
+
type Storage,
|
|
16
|
+
} from "../opencode/storage"
|
|
17
|
+
|
|
18
|
+
// The cache is revalidated after six hours (design §8).
|
|
19
|
+
const DEFAULT_TTL_MS = 6 * 60 * 60 * 1000
|
|
20
|
+
|
|
21
|
+
// A fetch that has not answered by then is aborted (design §4: "HTTPS fetch
|
|
22
|
+
// with timeout"). Without it a dead socket would hold the refresh tick's
|
|
23
|
+
// non-overlap guard forever.
|
|
24
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 30_000
|
|
25
|
+
|
|
26
|
+
// The body size cap (hostile-input hardening): `catalogURL` is user-pointable
|
|
27
|
+
// and the body is buffered whole for JSON.parse, so a hostile server must not
|
|
28
|
+
// be able to exhaust memory. 16 MiB is far above any sane catalog (the
|
|
29
|
+
// recorded fixture is well under 1 MiB).
|
|
30
|
+
const MAX_BODY_BYTES = 16 * 1024 * 1024 // 16 MiB
|
|
31
|
+
|
|
32
|
+
// Body-read failures share one taxonomy, the invalid-JSON reason style.
|
|
33
|
+
function bodyTooLargeReason(): string {
|
|
34
|
+
return `invalid JSON in response (response body exceeds the ${MAX_BODY_BYTES / (1024 * 1024)} MiB cap)`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Read the body as text, enforcing MAX_BODY_BYTES while reading: the stream
|
|
38
|
+
// is cancelled the moment the cap is crossed, so an endless or oversized body
|
|
39
|
+
// is never buffered further (a lying or absent Content-Length cannot bypass
|
|
40
|
+
// the cap).
|
|
41
|
+
async function readCappedText(
|
|
42
|
+
response: Response,
|
|
43
|
+
maxBytes: number,
|
|
44
|
+
): Promise<{ ok: true; text: string } | { ok: false }> {
|
|
45
|
+
const body = response.body
|
|
46
|
+
if (body === null) return { ok: true, text: "" }
|
|
47
|
+
const reader = body.getReader()
|
|
48
|
+
const chunks: Uint8Array[] = []
|
|
49
|
+
let total = 0
|
|
50
|
+
try {
|
|
51
|
+
for (;;) {
|
|
52
|
+
const { done, value } = await reader.read()
|
|
53
|
+
if (done) break
|
|
54
|
+
if (value === undefined) continue
|
|
55
|
+
total += value.byteLength
|
|
56
|
+
if (total > maxBytes) return { ok: false }
|
|
57
|
+
chunks.push(value)
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
// On the overflow path reading stops here — free the connection instead
|
|
61
|
+
// of leaving the stream (and the socket behind it) half-drained.
|
|
62
|
+
await reader.cancel().catch(() => {})
|
|
63
|
+
}
|
|
64
|
+
const bytes = new Uint8Array(total)
|
|
65
|
+
let offset = 0
|
|
66
|
+
for (const chunk of chunks) {
|
|
67
|
+
bytes.set(chunk, offset)
|
|
68
|
+
offset += chunk.byteLength
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, text: new TextDecoder().decode(bytes) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Injectable seams for tests: the network call, the plugin's clock, and the
|
|
74
|
+
// TTL (design §8). All default to their production behavior.
|
|
75
|
+
export type RemoteDeps = {
|
|
76
|
+
fetch?: typeof fetch
|
|
77
|
+
now?: () => number
|
|
78
|
+
ttlMs?: number
|
|
79
|
+
timeoutMs?: number
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// One fetch-and-validate attempt as a result object (the fetch itself, the
|
|
83
|
+
// HTTP status, the body parse, and the document each get their own reason).
|
|
84
|
+
// Network errors surface with their own message; non-OK statuses name the
|
|
85
|
+
// status code.
|
|
86
|
+
async function fetchAndValidate(
|
|
87
|
+
url: string,
|
|
88
|
+
doFetch: typeof fetch,
|
|
89
|
+
now: () => number,
|
|
90
|
+
timeoutMs: number,
|
|
91
|
+
): Promise<{ ok: true; sourced: Sourced } | { ok: false; reason: string }> {
|
|
92
|
+
let response: Response
|
|
93
|
+
try {
|
|
94
|
+
response = await doFetch(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
|
97
|
+
}
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
return { ok: false, reason: `HTTP status ${response.status}` }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Content-Length, when honest and present, fails before a single body byte
|
|
103
|
+
// is read; readCappedText enforces the same cap for the rest.
|
|
104
|
+
const declared = response.headers?.get("content-length")
|
|
105
|
+
if (declared !== null && declared !== undefined && /^\d+$/.test(declared) && Number(declared) > MAX_BODY_BYTES) {
|
|
106
|
+
await response.body?.cancel().catch(() => {})
|
|
107
|
+
return { ok: false, reason: bodyTooLargeReason() }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let raw: unknown
|
|
111
|
+
try {
|
|
112
|
+
const read = await readCappedText(response, MAX_BODY_BYTES)
|
|
113
|
+
if (!read.ok) return { ok: false, reason: bodyTooLargeReason() }
|
|
114
|
+
raw = JSON.parse(read.text)
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
117
|
+
return { ok: false, reason: `invalid JSON in response (${message})` }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const validated = validateCatalog(raw)
|
|
121
|
+
if (!validated.ok) {
|
|
122
|
+
return { ok: false, reason: `invalid catalog document: ${validated.reason}` }
|
|
123
|
+
}
|
|
124
|
+
return { ok: true, sourced: { doc: validated.catalog, acquiredAt: now() } }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fetch the remote catalog with a persisted six-hour cache (design §5, §8):
|
|
128
|
+
// a fresh record is returned without fetching; otherwise the document is
|
|
129
|
+
// fetched, validated, persisted in the document cache of this URL, and returned with
|
|
130
|
+
// fromCache: false. A fetch or validation failure returns the cached record
|
|
131
|
+
// whatever its age; with no usable record the reason is rejected.
|
|
132
|
+
export async function fetchRemote(
|
|
133
|
+
url: string,
|
|
134
|
+
storage: Storage,
|
|
135
|
+
deps: RemoteDeps = {},
|
|
136
|
+
): Promise<Sourced & { fromCache: boolean }> {
|
|
137
|
+
const doFetch = deps.fetch ?? globalThis.fetch
|
|
138
|
+
const now = deps.now ?? Date.now
|
|
139
|
+
const ttlMs = deps.ttlMs ?? DEFAULT_TTL_MS
|
|
140
|
+
|
|
141
|
+
const identity = remoteIdentity(url)
|
|
142
|
+
const cached = await readCachedDoc(storage, identity)
|
|
143
|
+
if (cached !== null && now() - cached.acquiredAt < ttlMs) {
|
|
144
|
+
return { ...cached, fromCache: true }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const attempt = await fetchAndValidate(url, doFetch, now, deps.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS)
|
|
148
|
+
if (attempt.ok) {
|
|
149
|
+
const { doc, acquiredAt } = attempt.sourced
|
|
150
|
+
await writeCachedDoc(storage, identity, { doc, acquiredAt })
|
|
151
|
+
return { doc, acquiredAt, fromCache: false }
|
|
152
|
+
}
|
|
153
|
+
if (cached !== null) return { ...cached, fromCache: true }
|
|
154
|
+
throw new Error(`remote source failed: ${attempt.reason}`)
|
|
155
|
+
}
|