@tinfoilsh/opencode-provider 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 +202 -0
- package/README.md +103 -0
- package/package.json +77 -0
- package/tinfoil.ts +755 -0
- package/tui.ts +334 -0
package/tinfoil.ts
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
// Types only; opencode injects the real runtime. Kept as a peer dependency so
|
|
2
|
+
// the plugin tracks whatever opencode version the user already has.
|
|
3
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
4
|
+
// Types only. The runtime import happens inside the factory, off opencode's
|
|
5
|
+
// startup path — see the `load` promise below. Resolves from this package's own
|
|
6
|
+
// node_modules when installed as an npm plugin, or from the opencode config
|
|
7
|
+
// directory when dropped in as a file.
|
|
8
|
+
import type { SecureClient as SecureClientInstance, VerificationDocument } from "tinfoil"
|
|
9
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
|
|
10
|
+
import { homedir } from "node:os"
|
|
11
|
+
import { dirname, join } from "node:path"
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Tinfoil provider for opencode.
|
|
15
|
+
*
|
|
16
|
+
* opencode already knows the `tinfoil` provider and its models through
|
|
17
|
+
* models.dev, so this plugin does not introduce a provider — it upgrades the
|
|
18
|
+
* transport of the one that is already there. The `tinfoil` SDK verifies the
|
|
19
|
+
* enclave's SEV-SNP attestation and its Sigstore-signed code digest in
|
|
20
|
+
* process, then seals every request body to the attested HPKE key. No local
|
|
21
|
+
* proxy, and no base URL, API key, or model list in opencode.json.
|
|
22
|
+
*
|
|
23
|
+
* Not a full external verifier (no independent AMD signature-chain check);
|
|
24
|
+
* for that use github.com/tinfoilsh/tinfoil-cli.
|
|
25
|
+
*
|
|
26
|
+
* See README.md for setup.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const PROVIDER_ID = "tinfoil"
|
|
30
|
+
const HELP_URL = "https://tinfoil.sh/coding-agents"
|
|
31
|
+
|
|
32
|
+
/** Hosts whose requests may be retargeted at the attested enclave. */
|
|
33
|
+
const TINFOIL_HOST = /(^|\.)tinfoil\.sh$/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where this half publishes its verdict for the TUI half to render.
|
|
37
|
+
*
|
|
38
|
+
* The two halves run in different processes (server vs TUI) and opencode has
|
|
39
|
+
* no channel between them — `tui.publish` only accepts three built-in event
|
|
40
|
+
* types. So the process that actually owns the SecureClient, and therefore
|
|
41
|
+
* actually enforces, writes what it decided; the sidebar only ever renders
|
|
42
|
+
* that. The panel can never claim verified while the guard is failing closed.
|
|
43
|
+
*
|
|
44
|
+
* Verification is a property of the enclave, not of a project, so a single
|
|
45
|
+
* shared file is correct even with several opencode windows open.
|
|
46
|
+
*/
|
|
47
|
+
const STATUS_PATH = join(homedir(), ".tinfoil", "opencode-status.json")
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The enclave's model list, cached so that a startup never blocks on
|
|
51
|
+
* attestation plus a round trip to fetch it. Refreshed behind each session.
|
|
52
|
+
*/
|
|
53
|
+
const CATALOG_PATH = join(homedir(), ".tinfoil", "opencode-models.json")
|
|
54
|
+
const CATALOG_VERSION = 1
|
|
55
|
+
|
|
56
|
+
/** Long enough to survive a holiday; short enough that a retired model goes. */
|
|
57
|
+
const CATALOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Bump when the shape changes; the reader ignores versions it knows nothing
|
|
61
|
+
* about.
|
|
62
|
+
*
|
|
63
|
+
* Note these are deliberately NOT exported. opencode's plugin loader walks
|
|
64
|
+
* every export of a server plugin module and throws "Plugin export is not a
|
|
65
|
+
* function" on the first one that is not callable — which silently unloads the
|
|
66
|
+
* whole plugin, guard included. tui.ts keeps its own copy of this contract.
|
|
67
|
+
*/
|
|
68
|
+
const STATUS_VERSION = 2
|
|
69
|
+
|
|
70
|
+
type TinfoilStatus = {
|
|
71
|
+
v: number
|
|
72
|
+
verified: boolean
|
|
73
|
+
/**
|
|
74
|
+
* Whether the guarded fetch is actually installed. Verification says the
|
|
75
|
+
* enclave is trustworthy; this says opencode is going through us to reach
|
|
76
|
+
* it. Both have to be true before the sidebar may claim anything.
|
|
77
|
+
*/
|
|
78
|
+
guarded: boolean
|
|
79
|
+
reason?: string
|
|
80
|
+
releaseTag?: string
|
|
81
|
+
releaseDigest?: string
|
|
82
|
+
enclaveHost?: string
|
|
83
|
+
/** Pre-rendered report, so the TUI never needs the SDK to display detail. */
|
|
84
|
+
report: string[]
|
|
85
|
+
at: number
|
|
86
|
+
pid: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Model discovery is a nicety; never let it hold up a session. */
|
|
90
|
+
const DISCOVER_TIMEOUT_MS = 8000
|
|
91
|
+
|
|
92
|
+
/** Floor between re-attestation attempts, so a hard outage cannot spin. */
|
|
93
|
+
const REVALIDATE_COOLDOWN_MS = 30_000
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How often to republish an unchanged verdict.
|
|
97
|
+
*
|
|
98
|
+
* The sidebar only trusts a verdict whose publishing process is still running,
|
|
99
|
+
* and the status file holds whichever server wrote last. Close one of two
|
|
100
|
+
* opencode windows and the survivor's sidebar would otherwise sit on a dead
|
|
101
|
+
* process's verdict — correct to distrust, but wrong about this session. A
|
|
102
|
+
* heartbeat means it recovers within one interval instead of never.
|
|
103
|
+
*/
|
|
104
|
+
const REPUBLISH_MS = 30_000
|
|
105
|
+
|
|
106
|
+
const debug = (message: string) => {
|
|
107
|
+
if (process.env["TINFOIL_DEBUG"]) console.error(`[tinfoil] ${message}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error))
|
|
111
|
+
|
|
112
|
+
// =============================================================================
|
|
113
|
+
// Verification state
|
|
114
|
+
// =============================================================================
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `pending` only exists before the first attempt settles. Everything that
|
|
118
|
+
* needs a verdict awaits `verify()` rather than reading this directly.
|
|
119
|
+
*/
|
|
120
|
+
type VerifyState = { kind: "pending" } | { kind: "verified" } | { kind: "failed"; reason: string }
|
|
121
|
+
|
|
122
|
+
export const TinfoilProvider: Plugin = async ({ client }) => {
|
|
123
|
+
let state: VerifyState = { kind: "pending" }
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Set once opencode has actually taken `guardedFetch`.
|
|
127
|
+
*
|
|
128
|
+
* Verifying the enclave proves nothing about the transport if opencode is
|
|
129
|
+
* still using its own `fetch`, so this gates every claim the plugin makes.
|
|
130
|
+
* It is not a formality: the `auth.loader` route silently does not happen
|
|
131
|
+
* unless the provider has a stored auth entry.
|
|
132
|
+
*/
|
|
133
|
+
let guarded = false
|
|
134
|
+
let attempt: Promise<VerifyState> | undefined
|
|
135
|
+
let lastAttemptAt = 0
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Default config: resolves the router, verifies SEV-SNP against the
|
|
139
|
+
* Sigstore-signed release digest, and sets up HPKE body encryption.
|
|
140
|
+
*
|
|
141
|
+
* Imported here rather than at the top of the file. opencode awaits a
|
|
142
|
+
* plugin's module evaluation during startup, and evaluating the `tinfoil`
|
|
143
|
+
* SDK costs ~300ms — paid before opencode has even asked this plugin for
|
|
144
|
+
* anything. Started here and deliberately not awaited, it overlaps the
|
|
145
|
+
* startup work opencode does anyway, and everything that needs the client
|
|
146
|
+
* awaits `verify()`.
|
|
147
|
+
*
|
|
148
|
+
* Nothing in this path may throw. When a plugin fails to load, opencode logs
|
|
149
|
+
* it and carries on — and the `tinfoil` provider still exists via
|
|
150
|
+
* models.dev, so the session would quietly fall back to sending prompts over
|
|
151
|
+
* plain TLS with no attestation at all. A plugin that loads is a plugin that
|
|
152
|
+
* can refuse; every failure becomes a state the guard can see rather than
|
|
153
|
+
* one that unloads the guard.
|
|
154
|
+
*/
|
|
155
|
+
let secure: SecureClientInstance | undefined
|
|
156
|
+
let loadFailure: string | undefined
|
|
157
|
+
const load = (async () => {
|
|
158
|
+
try {
|
|
159
|
+
const { SecureClient } = await import("tinfoil")
|
|
160
|
+
secure = new SecureClient()
|
|
161
|
+
} catch (error) {
|
|
162
|
+
loadFailure = `could not load the Tinfoil client: ${errorMessage(error)}`
|
|
163
|
+
debug(loadFailure)
|
|
164
|
+
}
|
|
165
|
+
})()
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Deduplicated verification. Never rejects: opencode invokes the auth loader
|
|
169
|
+
* through `Effect.promise`, where a rejected promise is a defect rather than
|
|
170
|
+
* a handled error, so a throw here can take down startup instead of
|
|
171
|
+
* disabling one provider. Failure is a value, and the fetch guard is what
|
|
172
|
+
* turns it into a blocked request.
|
|
173
|
+
*/
|
|
174
|
+
const verify = (): Promise<VerifyState> => {
|
|
175
|
+
attempt ??= (async () => {
|
|
176
|
+
lastAttemptAt = Date.now()
|
|
177
|
+
await load
|
|
178
|
+
if (!secure) {
|
|
179
|
+
state = { kind: "failed", reason: loadFailure ?? "the Tinfoil client is unavailable" }
|
|
180
|
+
void publishStatus()
|
|
181
|
+
return state
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
await secure.ready()
|
|
185
|
+
state = { kind: "verified" }
|
|
186
|
+
debug(`verified ${secure.getVerificationDocument().releaseDigest}`)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
state = { kind: "failed", reason: errorMessage(error) }
|
|
189
|
+
debug(`verification failed: ${state.reason}`)
|
|
190
|
+
}
|
|
191
|
+
void publishStatus()
|
|
192
|
+
return state
|
|
193
|
+
})()
|
|
194
|
+
return attempt
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Drop the cached attestation and check again from scratch. Covers both a
|
|
199
|
+
* transient startup failure and an enclave that restarted under us; the
|
|
200
|
+
* cooldown keeps a sustained outage from re-attesting on every request.
|
|
201
|
+
*/
|
|
202
|
+
const revalidate = async (): Promise<VerifyState> => {
|
|
203
|
+
if (!secure) return state
|
|
204
|
+
if (Date.now() - lastAttemptAt < REVALIDATE_COOLDOWN_MS) return state
|
|
205
|
+
secure.reset()
|
|
206
|
+
attempt = undefined
|
|
207
|
+
return verify()
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const document = (): VerificationDocument | undefined =>
|
|
211
|
+
state.kind === "verified" ? secure?.getVerificationDocument() : undefined
|
|
212
|
+
|
|
213
|
+
// =============================================================================
|
|
214
|
+
// Transport
|
|
215
|
+
// =============================================================================
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The guard. Fails closed: if the enclave is not verified, nothing leaves
|
|
219
|
+
* the machine — not the API key, system prompt, tool definitions, or the
|
|
220
|
+
* user's code. A failed verification cannot send your prompts anywhere.
|
|
221
|
+
*
|
|
222
|
+
* `secure.fetch` seals each body to the attested HPKE key, refuses any
|
|
223
|
+
* origin other than the verified enclave, and re-attests on its own when the
|
|
224
|
+
* server rotates keys.
|
|
225
|
+
*/
|
|
226
|
+
const guardedFetch: typeof fetch = async (input, init) => {
|
|
227
|
+
let current = await verify()
|
|
228
|
+
if (current.kind !== "verified") current = await revalidate()
|
|
229
|
+
if (current.kind !== "verified") {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Tinfoil: refusing to send this request. Enclave verification failed: ` +
|
|
232
|
+
`${current.kind === "failed" ? current.reason : "verification did not complete"}. See ${HELP_URL}`,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
return secure!.fetch(retarget(input), init)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Point a request at the enclave the attestation actually covers.
|
|
240
|
+
*
|
|
241
|
+
* Which router that is only becomes known once verification finishes, and
|
|
242
|
+
* the SDK's fetch hard-refuses every other origin ("this client is bound to
|
|
243
|
+
* the verified enclave/proxy"). The alternative is to hand opencode a
|
|
244
|
+
* `baseURL` from the auth loader, but that means the loader has to block
|
|
245
|
+
* startup on attestation — and the router genuinely varies between runs, so
|
|
246
|
+
* a remembered URL is not safe either. Rewriting here costs nothing and
|
|
247
|
+
* keeps the base URL question off the startup path entirely.
|
|
248
|
+
*
|
|
249
|
+
* Only Tinfoil hosts are rewritten. Anything else is passed through
|
|
250
|
+
* untouched for the SDK to refuse, so a bug elsewhere in opencode cannot
|
|
251
|
+
* turn this into an open relay to the enclave.
|
|
252
|
+
*/
|
|
253
|
+
const retarget = (input: Parameters<typeof fetch>[0]): Parameters<typeof fetch>[0] => {
|
|
254
|
+
const base = secure?.getBaseURL()
|
|
255
|
+
if (!base) return input
|
|
256
|
+
const requested = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
|
257
|
+
let url: URL
|
|
258
|
+
try {
|
|
259
|
+
url = new URL(requested)
|
|
260
|
+
} catch {
|
|
261
|
+
// Already relative; the SDK resolves it against the verified enclave.
|
|
262
|
+
return input
|
|
263
|
+
}
|
|
264
|
+
if (!TINFOIL_HOST.test(url.hostname)) return input
|
|
265
|
+
const target = `${new URL(base).origin}${url.pathname}${url.search}`
|
|
266
|
+
if (target === url.href) return input
|
|
267
|
+
debug(`retargeting ${url.origin} to the attested enclave`)
|
|
268
|
+
return typeof input === "string" || input instanceof URL ? target : new Request(target, input)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// =============================================================================
|
|
272
|
+
// Model discovery
|
|
273
|
+
// =============================================================================
|
|
274
|
+
|
|
275
|
+
interface TinfoilApiModel {
|
|
276
|
+
id?: string
|
|
277
|
+
name?: string
|
|
278
|
+
type?: string
|
|
279
|
+
endpoints?: string[]
|
|
280
|
+
context_window?: number
|
|
281
|
+
max_tokens?: number
|
|
282
|
+
reasoning?: boolean
|
|
283
|
+
multimodal?: boolean
|
|
284
|
+
tool_calling?: boolean
|
|
285
|
+
pricing?: { inputTokenPricePer1M?: number; outputTokenPricePer1M?: number }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Everything below treats the model list as untrusted input.
|
|
290
|
+
*
|
|
291
|
+
* It arrives from the enclave over the verified channel and is then cached on
|
|
292
|
+
* disk for a week, so a single bad response is not a transient problem — it
|
|
293
|
+
* is a file that keeps being read back. And the consumer is unforgiving:
|
|
294
|
+
* opencode calls `provider.models` through `Effect.promise`, where a rejected
|
|
295
|
+
* promise is a defect rather than a handled error, so one bad entry that
|
|
296
|
+
* throws takes down every provider in the session, not just this one.
|
|
297
|
+
*/
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A model id has to be safe both as an object key and as a lookup into an
|
|
301
|
+
* object literal. `__proto__` would set a prototype instead of adding an
|
|
302
|
+
* entry; `constructor`, `toString` and friends are only dangerous on the
|
|
303
|
+
* lookup side, which uses `Object.hasOwn`, but there is no reason to accept
|
|
304
|
+
* them. Control characters would reach the terminal.
|
|
305
|
+
*/
|
|
306
|
+
const isSafeId = (id: unknown): id is string =>
|
|
307
|
+
typeof id === "string" &&
|
|
308
|
+
id.length > 0 &&
|
|
309
|
+
id.length <= 256 &&
|
|
310
|
+
id !== "__proto__" &&
|
|
311
|
+
// eslint-disable-next-line no-control-regex
|
|
312
|
+
!/[\u0000-\u001f\u007f]/.test(id)
|
|
313
|
+
|
|
314
|
+
/** A coding agent needs chat plus tool calling; anything else fails in the picker. */
|
|
315
|
+
const isUsable = (raw: unknown): raw is TinfoilApiModel => {
|
|
316
|
+
if (!raw || typeof raw !== "object") return false
|
|
317
|
+
const model = raw as TinfoilApiModel
|
|
318
|
+
if (!isSafeId(model.id)) return false
|
|
319
|
+
if (model.type && model.type !== "chat") return false
|
|
320
|
+
if (Array.isArray(model.endpoints) && !model.endpoints.includes("/v1/chat/completions")) return false
|
|
321
|
+
if (model.tool_calling === false) return false
|
|
322
|
+
return true
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Numbers from the enclave land in opencode's context accounting and cost
|
|
327
|
+
* display, where a string or a NaN is not caught but quietly propagates.
|
|
328
|
+
*/
|
|
329
|
+
const CONTEXT_CEILING = 10_000_000
|
|
330
|
+
|
|
331
|
+
const positiveInt = (value: unknown, fallback: number): number => {
|
|
332
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return fallback
|
|
333
|
+
return Math.min(Math.floor(value), CONTEXT_CEILING)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const price = (value: unknown, fallback: number): number =>
|
|
337
|
+
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback
|
|
338
|
+
|
|
339
|
+
/** /v1/models reports no output-token limit; derive a conservative one. */
|
|
340
|
+
const deriveMaxTokens = (contextWindow: number): number =>
|
|
341
|
+
Math.min(32768, Math.max(4096, Math.floor(contextWindow / 8)))
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Build a catalog from what the enclave serves, keeping models.dev as the
|
|
345
|
+
* schema.
|
|
346
|
+
*
|
|
347
|
+
* opencode replaces `provider.models` wholesale with whatever the hook
|
|
348
|
+
* returns and fills in no defaults, so every entry has to be a complete
|
|
349
|
+
* model record. Rather than hand-roll one — and re-break on the next schema
|
|
350
|
+
* change — clone an entry opencode already built from models.dev and
|
|
351
|
+
* override only the fields the enclave actually reports. Models the enclave
|
|
352
|
+
* no longer serves fall out; ones models.dev has not caught up with appear.
|
|
353
|
+
*/
|
|
354
|
+
const buildCatalog = (existing: Record<string, any>, live: TinfoilApiModel[]): Record<string, any> | undefined => {
|
|
355
|
+
const template = Object.values(existing)[0]
|
|
356
|
+
if (!template) {
|
|
357
|
+
// No models.dev entry to clone, so there is no safe shape to build.
|
|
358
|
+
// Leave the catalog alone rather than guess at required fields.
|
|
359
|
+
debug("no catalog entry to use as a template")
|
|
360
|
+
return undefined
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const models: Record<string, any> = {}
|
|
364
|
+
for (const raw of live) {
|
|
365
|
+
if (!isUsable(raw)) continue
|
|
366
|
+
const id = raw.id as string
|
|
367
|
+
const contextWindow = positiveInt(raw.context_window, positiveInt(template.limit?.context, 128000))
|
|
368
|
+
// `Object.hasOwn`, not `existing[id]`: a bare lookup finds inherited
|
|
369
|
+
// members, so an id of `constructor` or `toString` would clone a function
|
|
370
|
+
// and `structuredClone` would throw.
|
|
371
|
+
const known = Object.hasOwn(existing, id) ? existing[id] : undefined
|
|
372
|
+
let model: any
|
|
373
|
+
try {
|
|
374
|
+
model = structuredClone(known ?? template)
|
|
375
|
+
} catch (error) {
|
|
376
|
+
debug(`skipping model ${id}: ${errorMessage(error)}`)
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// A cloned template carries the donor model's identity-ish fields. Left
|
|
381
|
+
// in place they would advertise variants and a release date belonging to
|
|
382
|
+
// a different model; drop them and let opencode fall back to defaults.
|
|
383
|
+
if (!known) {
|
|
384
|
+
delete model.variants
|
|
385
|
+
delete model.family
|
|
386
|
+
model.status = "active"
|
|
387
|
+
model.release_date = ""
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
model.id = id
|
|
391
|
+
model.name = raw.name ?? id
|
|
392
|
+
// No `api.url`: `retarget` sends every request to the attested enclave,
|
|
393
|
+
// whichever router that turns out to be.
|
|
394
|
+
model.api = { ...model.api, id }
|
|
395
|
+
model.limit = {
|
|
396
|
+
...model.limit,
|
|
397
|
+
context: contextWindow,
|
|
398
|
+
output: positiveInt(raw.max_tokens, deriveMaxTokens(contextWindow)),
|
|
399
|
+
}
|
|
400
|
+
model.capabilities = {
|
|
401
|
+
...model.capabilities,
|
|
402
|
+
toolcall: true,
|
|
403
|
+
reasoning: raw.reasoning === true,
|
|
404
|
+
attachment: raw.multimodal === true,
|
|
405
|
+
input: { ...model.capabilities?.input, text: true, image: raw.multimodal === true },
|
|
406
|
+
}
|
|
407
|
+
if (raw.pricing && typeof raw.pricing === "object") {
|
|
408
|
+
model.cost = {
|
|
409
|
+
...model.cost,
|
|
410
|
+
input: price(raw.pricing.inputTokenPricePer1M, price(model.cost?.input, 0)),
|
|
411
|
+
output: price(raw.pricing.outputTokenPricePer1M, price(model.cost?.output, 0)),
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
models[id] = model
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!Object.keys(models).length) {
|
|
418
|
+
debug("enclave served no usable chat models")
|
|
419
|
+
return undefined
|
|
420
|
+
}
|
|
421
|
+
return models
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The enclave's model list as last seen, so a startup never waits for it. */
|
|
425
|
+
const readCache = async (): Promise<TinfoilApiModel[] | undefined> => {
|
|
426
|
+
try {
|
|
427
|
+
const raw = JSON.parse(await readFile(CATALOG_PATH, "utf8")) as {
|
|
428
|
+
v?: number
|
|
429
|
+
at?: number
|
|
430
|
+
models?: TinfoilApiModel[]
|
|
431
|
+
}
|
|
432
|
+
if (raw.v !== CATALOG_VERSION || !Array.isArray(raw.models)) return undefined
|
|
433
|
+
if (Date.now() - (raw.at ?? 0) > CATALOG_MAX_AGE_MS) return undefined
|
|
434
|
+
// Written by a previous run, but read as untrusted all the same: the file
|
|
435
|
+
// outlives the response that produced it, and it is a plain file on disk.
|
|
436
|
+
return raw.models.filter(isUsable)
|
|
437
|
+
} catch {
|
|
438
|
+
return undefined
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Fetch the live list and cache it for the next start. Runs in the
|
|
444
|
+
* background: model discovery is a nicety, and waiting for attestation plus
|
|
445
|
+
* a round trip is startup time nobody asked for. This session keeps whatever
|
|
446
|
+
* catalog it started with.
|
|
447
|
+
*/
|
|
448
|
+
const refreshCache = async (): Promise<void> => {
|
|
449
|
+
try {
|
|
450
|
+
const verdict = await verify()
|
|
451
|
+
if (verdict.kind !== "verified") {
|
|
452
|
+
debug("skipping catalog refresh: not verified")
|
|
453
|
+
return
|
|
454
|
+
}
|
|
455
|
+
const response = await secure!.fetch("/v1/models", {
|
|
456
|
+
signal: AbortSignal.timeout(DISCOVER_TIMEOUT_MS),
|
|
457
|
+
})
|
|
458
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} from /v1/models`)
|
|
459
|
+
const payload = (await response.json()) as { data?: unknown }
|
|
460
|
+
const live = Array.isArray(payload.data) ? payload.data.filter(isUsable) : []
|
|
461
|
+
if (!live.length) {
|
|
462
|
+
debug("enclave served no usable chat models; keeping the cached list")
|
|
463
|
+
return
|
|
464
|
+
}
|
|
465
|
+
await writeAtomic(CATALOG_PATH, JSON.stringify({ v: CATALOG_VERSION, at: Date.now(), models: live }))
|
|
466
|
+
debug(`cached ${live.length} models from the enclave`)
|
|
467
|
+
} catch (error) {
|
|
468
|
+
// models.dev remains a reasonable catalog, and the guard covers every
|
|
469
|
+
// request either way.
|
|
470
|
+
debug(`catalog refresh failed: ${errorMessage(error)}`)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* The `provider.models` hook. Returns immediately — from the cached list
|
|
476
|
+
* where there is one, otherwise leaving models.dev in place — and refreshes
|
|
477
|
+
* the cache behind the session.
|
|
478
|
+
*/
|
|
479
|
+
const discover = async (provider: any): Promise<Record<string, any>> => {
|
|
480
|
+
const existing: Record<string, any> = provider.models ?? {}
|
|
481
|
+
try {
|
|
482
|
+
void refreshCache()
|
|
483
|
+
|
|
484
|
+
const cached = await readCache()
|
|
485
|
+
if (!cached) {
|
|
486
|
+
debug("no cached model list, keeping the models.dev catalog")
|
|
487
|
+
return existing
|
|
488
|
+
}
|
|
489
|
+
const models = buildCatalog(existing, cached)
|
|
490
|
+
if (!models) return existing
|
|
491
|
+
debug(`serving ${Object.keys(models).length} models from the cached enclave list`)
|
|
492
|
+
return models
|
|
493
|
+
} catch (error) {
|
|
494
|
+
// A rejection from this hook is an opencode-wide defect, not a provider
|
|
495
|
+
// that lost its model list. Nothing here is worth that.
|
|
496
|
+
debug(`catalog build failed (${errorMessage(error)}), keeping the models.dev catalog`)
|
|
497
|
+
return existing
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Write via a temp file and rename, so a reader never sees a half-written
|
|
503
|
+
* document. Mode 0600: the contents are public information — a release
|
|
504
|
+
* digest and public keys — but nothing else has any business writing what
|
|
505
|
+
* the sidebar reads.
|
|
506
|
+
*
|
|
507
|
+
* 0700 on the directory to match what the SDK creates it with. Whoever gets
|
|
508
|
+
* there first decides: recursive `mkdir` leaves an existing directory's mode
|
|
509
|
+
* alone, so on a machine where this plugin runs before the SDK has written
|
|
510
|
+
* anything, a default 0755 would be what `~/.tinfoil` keeps.
|
|
511
|
+
*/
|
|
512
|
+
const writeAtomic = async (path: string, contents: string): Promise<void> => {
|
|
513
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
514
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
515
|
+
await writeFile(temporary, contents, { encoding: "utf8", mode: 0o600 })
|
|
516
|
+
await rename(temporary, path)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// =============================================================================
|
|
520
|
+
// Status
|
|
521
|
+
// =============================================================================
|
|
522
|
+
|
|
523
|
+
const shortHash = (value?: string) => (value ? value.replace(/^sha256:/, "").slice(0, 12) : "unknown")
|
|
524
|
+
|
|
525
|
+
/** One-line verdict, as the sidebar headline and the failure toast show it. */
|
|
526
|
+
const summary = (verdict: VerifyState): string => {
|
|
527
|
+
if (!guarded) {
|
|
528
|
+
return (
|
|
529
|
+
`Tinfoil unprotected [!] — opencode is not routing this provider through Tinfoil, ` +
|
|
530
|
+
`so requests are NOT verified or encrypted to an enclave. See ${HELP_URL}`
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
if (verdict.kind !== "verified") {
|
|
534
|
+
const reason = verdict.kind === "failed" ? verdict.reason : "verification did not complete"
|
|
535
|
+
return `Tinfoil unverified [!] — ${reason}. Requests are blocked. See ${HELP_URL}`
|
|
536
|
+
}
|
|
537
|
+
const doc = document()
|
|
538
|
+
return `Tinfoil verified [✓] ${doc?.releaseTag ?? "unknown"} ${shortHash(doc?.releaseDigest)}`
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** One step line, in the order the verifier performs the steps. */
|
|
542
|
+
const stepLines = (doc: VerificationDocument): string[] => {
|
|
543
|
+
const steps = doc.steps
|
|
544
|
+
if (!steps) return []
|
|
545
|
+
const entries: Array<[string, { status?: string; error?: string } | undefined]> = [
|
|
546
|
+
["Fetch digest", steps.fetchDigest],
|
|
547
|
+
["Verify code", steps.verifyCode],
|
|
548
|
+
["Verify enclave", steps.verifyEnclave],
|
|
549
|
+
["Compare measurements", steps.compareMeasurements],
|
|
550
|
+
["Verify certificate", steps.verifyCertificate],
|
|
551
|
+
]
|
|
552
|
+
return entries
|
|
553
|
+
.filter((pair): pair is [string, { status?: string; error?: string }] => pair[1] !== undefined)
|
|
554
|
+
.map(([name, step]) => ` ${name.padEnd(22)}${step.status ?? "unknown"}${step.error ? `: ${step.error}` : ""}`)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The full document, as `/tinfoil` renders it. */
|
|
558
|
+
const report = (verdict: VerifyState): string => {
|
|
559
|
+
const doc = document()
|
|
560
|
+
if (!guarded) {
|
|
561
|
+
return [
|
|
562
|
+
summary(verdict),
|
|
563
|
+
"",
|
|
564
|
+
"What this means",
|
|
565
|
+
" opencode is sending this provider's requests with its own HTTP client",
|
|
566
|
+
" rather than Tinfoil's, so nothing in this session is attested or",
|
|
567
|
+
" encrypted to an enclave, whatever the enclave itself reports.",
|
|
568
|
+
"",
|
|
569
|
+
" This should not happen. Please report it, with your opencode version,",
|
|
570
|
+
" at https://github.com/tinfoilsh/opencode-provider/issues",
|
|
571
|
+
].join("\n")
|
|
572
|
+
}
|
|
573
|
+
if (verdict.kind !== "verified" || !doc) {
|
|
574
|
+
return [
|
|
575
|
+
summary(verdict),
|
|
576
|
+
"",
|
|
577
|
+
`Reason: ${verdict.kind === "failed" ? verdict.reason : "no verification document"}`,
|
|
578
|
+
`The next request retries verification. See ${HELP_URL}`,
|
|
579
|
+
].join("\n")
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const enclave = doc.enclaveMeasurement ?? {}
|
|
583
|
+
return [
|
|
584
|
+
summary(verdict),
|
|
585
|
+
"",
|
|
586
|
+
"What this means",
|
|
587
|
+
" Nobody can read what you send to Tinfoil, including Tinfoil.",
|
|
588
|
+
" Your prompts and code are encrypted directly to this verified enclave,",
|
|
589
|
+
" and that is the only place they are ever decrypted.",
|
|
590
|
+
"",
|
|
591
|
+
"Connection",
|
|
592
|
+
` Base URL: ${secure?.getBaseURL() ?? "unknown"}`,
|
|
593
|
+
` Enclave host: ${doc.enclaveHost || "unknown"}`,
|
|
594
|
+
` Router endpoint: ${doc.selectedRouterEndpoint || "unknown"}`,
|
|
595
|
+
` Config repo: ${doc.configRepo || "unknown"}`,
|
|
596
|
+
"",
|
|
597
|
+
"Release",
|
|
598
|
+
` Tag: ${doc.releaseTag ?? "unknown"}`,
|
|
599
|
+
` Digest: ${doc.releaseDigest || "unknown"}`,
|
|
600
|
+
` Code print: ${doc.codeFingerprint || "unknown"}`,
|
|
601
|
+
` Enclave print: ${doc.enclaveFingerprint || "unknown"}`,
|
|
602
|
+
"",
|
|
603
|
+
"Attested keys",
|
|
604
|
+
` TLS public key: ${doc.tlsPublicKey || "unknown"}`,
|
|
605
|
+
` TLS fingerprint: ${enclave.tlsPublicKeyFingerprint ?? "unknown"}`,
|
|
606
|
+
` HPKE public key: ${doc.hpkePublicKey || enclave.hpkePublicKey || "unknown"}`,
|
|
607
|
+
"",
|
|
608
|
+
"Verification steps",
|
|
609
|
+
...stepLines(doc),
|
|
610
|
+
"",
|
|
611
|
+
"Verifier",
|
|
612
|
+
` Verifier: ${doc.verifier?.name ?? "unknown"} ${doc.verifier?.version ?? ""}`.trimEnd(),
|
|
613
|
+
` Verified: ${doc.securityVerified === true ? "yes" : "(SDK did not mark this document verified)"}`,
|
|
614
|
+
` Verified at: ${doc.verifiedAt ?? "unknown"}`,
|
|
615
|
+
].join("\n")
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Publish the verdict for the sidebar. Written via a temp file and rename so
|
|
620
|
+
* a reader never sees a half-written document, and entirely best-effort: a
|
|
621
|
+
* read-only home directory should cost you the panel, not the session.
|
|
622
|
+
*/
|
|
623
|
+
const publishStatus = async () => {
|
|
624
|
+
const verdict = state
|
|
625
|
+
if (verdict.kind === "pending") return
|
|
626
|
+
const doc = document()
|
|
627
|
+
try {
|
|
628
|
+
const status: TinfoilStatus = {
|
|
629
|
+
v: STATUS_VERSION,
|
|
630
|
+
verified: verdict.kind === "verified",
|
|
631
|
+
guarded,
|
|
632
|
+
...(verdict.kind === "failed" ? { reason: verdict.reason } : {}),
|
|
633
|
+
...(doc?.releaseTag ? { releaseTag: doc.releaseTag } : {}),
|
|
634
|
+
...(doc?.releaseDigest ? { releaseDigest: doc.releaseDigest } : {}),
|
|
635
|
+
...(doc?.enclaveHost ? { enclaveHost: doc.enclaveHost } : {}),
|
|
636
|
+
report: report(verdict).split("\n"),
|
|
637
|
+
at: Date.now(),
|
|
638
|
+
pid: process.pid,
|
|
639
|
+
}
|
|
640
|
+
await writeAtomic(STATUS_PATH, JSON.stringify(status))
|
|
641
|
+
debug(`published status verified=${status.verified} guarded=${status.guarded}`)
|
|
642
|
+
} catch (error) {
|
|
643
|
+
debug(`could not publish status: ${errorMessage(error)}`)
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Kick off attestation now, but do not await it: plugin load is on
|
|
648
|
+
// opencode's startup path and this is a network round trip. Everything that
|
|
649
|
+
// depends on the verdict awaits `verify()` where it actually needs it.
|
|
650
|
+
void verify()
|
|
651
|
+
|
|
652
|
+
const heartbeat = setInterval(() => void publishStatus(), REPUBLISH_MS)
|
|
653
|
+
heartbeat.unref?.()
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* There is no TUI in `opencode run`, and the toast call does not fail there
|
|
657
|
+
* so much as never answer. Racing it keeps a headless session from stalling
|
|
658
|
+
* on a cosmetic notification.
|
|
659
|
+
*/
|
|
660
|
+
const toast = async (message: string, variant: "error") => {
|
|
661
|
+
const shown = client.tui
|
|
662
|
+
.showToast({ body: { title: "Tinfoil", message, variant } })
|
|
663
|
+
.then(() => {})
|
|
664
|
+
.catch(() => {})
|
|
665
|
+
await Promise.race([shown, new Promise<void>((resolve) => setTimeout(resolve, 2000).unref?.())])
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Only failures toast. The sidebar panel is a standing, always-visible signal
|
|
670
|
+
* for the good case, so a success toast on every session would be noise that
|
|
671
|
+
* trains people to dismiss the one message that matters. A failure deserves
|
|
672
|
+
* interrupting either way: requests are blocked from here on, or — worse —
|
|
673
|
+
* they are not going through the guard at all.
|
|
674
|
+
*/
|
|
675
|
+
let announced = false
|
|
676
|
+
|
|
677
|
+
const announce = async () => {
|
|
678
|
+
if (announced) return
|
|
679
|
+
announced = true
|
|
680
|
+
const verdict = await verify()
|
|
681
|
+
if (verdict.kind === "verified" && guarded) return
|
|
682
|
+
await toast(summary(verdict), "error")
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return {
|
|
686
|
+
/**
|
|
687
|
+
* Where the guarded fetch is actually installed.
|
|
688
|
+
*
|
|
689
|
+
* `auth.loader` is the documented place for provider options, but opencode
|
|
690
|
+
* only calls it when the provider has a stored auth entry — so a user who
|
|
691
|
+
* supplies `TINFOIL_API_KEY` and never runs `opencode auth login` would get
|
|
692
|
+
* opencode's own `fetch`, the models.dev base URL, and no attestation, no
|
|
693
|
+
* body sealing and no guard, while this plugin cheerfully reported a
|
|
694
|
+
* verified enclave. The config hook runs either way.
|
|
695
|
+
*
|
|
696
|
+
* The loader still sets it too, for whichever of the two opencode consults
|
|
697
|
+
* first; both install the same function, so the duplication is harmless.
|
|
698
|
+
*/
|
|
699
|
+
async config(config) {
|
|
700
|
+
const providers = ((config as Record<string, any>)["provider"] ??= {})
|
|
701
|
+
const entry = (providers[PROVIDER_ID] ??= {})
|
|
702
|
+
const options = (entry.options ??= {})
|
|
703
|
+
options.fetch = guardedFetch
|
|
704
|
+
guarded = true
|
|
705
|
+
debug("installed the request guard")
|
|
706
|
+
},
|
|
707
|
+
|
|
708
|
+
async dispose() {
|
|
709
|
+
clearInterval(heartbeat)
|
|
710
|
+
},
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Registers "Tinfoil" under `opencode auth login` and, once a key is
|
|
714
|
+
* stored, hands opencode the guarded fetch.
|
|
715
|
+
*
|
|
716
|
+
* Deliberately does not wait for a verdict. This runs on the startup path,
|
|
717
|
+
* and the base URL it would have waited for is applied per request by
|
|
718
|
+
* `retarget` instead. Nothing is sent before verification either way: the
|
|
719
|
+
* guard is in the fetch.
|
|
720
|
+
*/
|
|
721
|
+
auth: {
|
|
722
|
+
provider: PROVIDER_ID,
|
|
723
|
+
methods: [{ type: "api", label: "Tinfoil API key" }],
|
|
724
|
+
async loader(auth) {
|
|
725
|
+
// `auth()` is opencode's; a throw here would reach `Effect.promise` as
|
|
726
|
+
// a defect and take down startup rather than one provider.
|
|
727
|
+
let stored: any
|
|
728
|
+
try {
|
|
729
|
+
stored = await auth()
|
|
730
|
+
} catch (error) {
|
|
731
|
+
debug(`could not read stored auth: ${errorMessage(error)}`)
|
|
732
|
+
}
|
|
733
|
+
guarded = true
|
|
734
|
+
return {
|
|
735
|
+
...(stored?.key ? { apiKey: stored.key } : {}),
|
|
736
|
+
fetch: guardedFetch,
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
|
|
741
|
+
provider: {
|
|
742
|
+
id: PROVIDER_ID,
|
|
743
|
+
models: discover,
|
|
744
|
+
},
|
|
745
|
+
|
|
746
|
+
// Deliberately not awaited. opencode awaits this hook for every event, so
|
|
747
|
+
// anything slow here is felt on the session's critical path; the status
|
|
748
|
+
// message is cosmetic and the request guard is what actually enforces.
|
|
749
|
+
async event() {
|
|
750
|
+
void announce()
|
|
751
|
+
},
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
export default TinfoilProvider
|