@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.
Files changed (5) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +103 -0
  3. package/package.json +77 -0
  4. package/tinfoil.ts +755 -0
  5. 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