@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/tui.ts ADDED
@@ -0,0 +1,334 @@
1
+ // The TUI half of the plugin. opencode loads this from the "./tui" subpath
2
+ // export, in the TUI process — separate from the server half in tinfoil.ts.
3
+ // It is registered in tui.json, not in the `plugin` array of opencode.json;
4
+ // `opencode plugin <spec>` writes both.
5
+ import type { TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
6
+ import { watch, type FSWatcher } from "node:fs"
7
+ import { readFile } from "node:fs/promises"
8
+ import { homedir } from "node:os"
9
+ import { basename, dirname, join } from "node:path"
10
+
11
+ /**
12
+ * Tinfoil status panel for opencode's sidebar, alongside Context and LSP.
13
+ *
14
+ * This half renders and nothing else. It never verifies anything itself: the
15
+ * server half owns the SecureClient and is what actually blocks unverified
16
+ * requests, so it publishes its verdict and this reads it. Attesting again here
17
+ * would be a second opinion that could disagree with the one being enforced,
18
+ * and a sidebar claiming "verified" while the guard fails closed is worse than
19
+ * no sidebar at all.
20
+ */
21
+
22
+ /** Must match STATUS_PATH / TinfoilStatus in tinfoil.ts. */
23
+ const STATUS_PATH = join(homedir(), ".tinfoil", "opencode-status.json")
24
+ const STATUS_VERSION = 2
25
+
26
+ type TinfoilStatus = {
27
+ v: number
28
+ verified: boolean
29
+ guarded: boolean
30
+ reason?: string
31
+ releaseTag?: string
32
+ releaseDigest?: string
33
+ enclaveHost?: string
34
+ report: string[]
35
+ at: number
36
+ pid: number
37
+ }
38
+
39
+ /**
40
+ * A backstop only. The file is watched, so this exists for the filesystems
41
+ * where watching quietly does not work rather than as the way updates arrive.
42
+ */
43
+ const POLL_MS = 15_000
44
+
45
+ /**
46
+ * A last-resort bound in case a pid is reused by an unrelated process. The
47
+ * real freshness check is whether the process that published the verdict is
48
+ * still running — see `isRunning`.
49
+ */
50
+ const STALE_MS = 12 * 60 * 60 * 1000
51
+
52
+ const MARK_OK = "✓"
53
+ const MARK_BAD = "!"
54
+
55
+ type View =
56
+ | { kind: "unknown" }
57
+ | { kind: "verified"; status: TinfoilStatus }
58
+ | { kind: "unverified"; status: TinfoilStatus }
59
+ /**
60
+ * The enclave may be perfectly verified and yet opencode is not routing this
61
+ * provider through us, in which case none of it applies to the session. A
62
+ * separate state, because "unverified — requests blocked" would be a
63
+ * comforting lie: nothing is blocked, it is simply unprotected.
64
+ */
65
+ | { kind: "unprotected"; status: TinfoilStatus }
66
+
67
+ /**
68
+ * Is the process that published this verdict still running?
69
+ *
70
+ * Signal 0 checks for existence without delivering anything. `EPERM` means the
71
+ * process is there but owned by someone else, which still counts as running.
72
+ *
73
+ * This is what keeps a dead server's "verified" from being presented as the
74
+ * current state: the verdict is only as live as the process enforcing it. A
75
+ * time window cannot do that job — the server rewrites the file whenever it
76
+ * re-verifies, so a legitimate verdict can be hours old in a long session,
77
+ * while a stale one from a killed server is only ever seconds old.
78
+ */
79
+ const isRunning = (pid?: number): boolean => {
80
+ if (!pid || pid <= 0) return false
81
+ try {
82
+ process.kill(pid, 0)
83
+ return true
84
+ } catch (error) {
85
+ return (error as NodeJS.ErrnoException)?.code === "EPERM"
86
+ }
87
+ }
88
+
89
+ const read = async (): Promise<View> => {
90
+ try {
91
+ const raw = JSON.parse(await readFile(STATUS_PATH, "utf8")) as TinfoilStatus
92
+ if (raw?.v !== STATUS_VERSION || typeof raw.verified !== "boolean") return { kind: "unknown" }
93
+ if (typeof raw.guarded !== "boolean") return { kind: "unknown" }
94
+ if (Date.now() - (raw.at ?? 0) > STALE_MS) return { kind: "unknown" }
95
+ if (!isRunning(raw.pid)) return { kind: "unknown" }
96
+ if (!raw.guarded) return { kind: "unprotected", status: raw }
97
+ return raw.verified ? { kind: "verified", status: raw } : { kind: "unverified", status: raw }
98
+ } catch {
99
+ // No file yet (server still attesting), or unreadable. Either way we do not
100
+ // know, and saying so is the honest render.
101
+ return { kind: "unknown" }
102
+ }
103
+ }
104
+
105
+ const shortHash = (value?: string) => (value ? value.replace(/^sha256:/, "").slice(0, 12) : "unknown")
106
+
107
+ const debug = (message: string) => {
108
+ if (process.env["TINFOIL_DEBUG"]) console.error(`[tinfoil-tui] ${message}`)
109
+ }
110
+
111
+ type JsxFactory = (
112
+ type: string | ((props: Record<string, unknown>) => unknown),
113
+ props?: Record<string, unknown> | null,
114
+ ) => unknown
115
+
116
+ const plugin: TuiPluginModule = {
117
+ id: "tinfoil",
118
+ async tui(api: TuiPluginApi) {
119
+ /**
120
+ * Imported here rather than at the top of the file, deliberately.
121
+ *
122
+ * opencode serves its own opentui and solid to plugins through an internal
123
+ * `opentui:runtime-module:` alias, and there must be exactly one copy of
124
+ * each: a second `@opentui/core` throws on load ("OPENTUI_FORCE_WCWIDTH is
125
+ * already registered with different configuration") and a second solid-js
126
+ * would silently break reactivity. Static imports are hoisted and resolve
127
+ * before that alias applies, so they pick up whatever copy happens to be in
128
+ * node_modules; by the time `tui()` runs, a dynamic import gets opencode's.
129
+ *
130
+ * Symptom if this regresses: the module never evaluates, `tui()` is never
131
+ * called, and nothing is logged anywhere — the panel simply never appears.
132
+ */
133
+ const { jsx } = (await import("@opentui/solid/jsx-runtime")) as unknown as { jsx: JsxFactory }
134
+ const { createSignal } = (await import("solid-js")) as unknown as {
135
+ createSignal: <T>(value: T) => [() => T, (next: T) => void]
136
+ }
137
+
138
+ const [view, setView] = createSignal<View>({ kind: "unknown" })
139
+
140
+ const refresh = async () => {
141
+ const next = await read()
142
+ setView(next)
143
+ debug(`status=${next.kind}`)
144
+ }
145
+ void refresh()
146
+
147
+ /**
148
+ * Watch the directory rather than the file: the server half publishes by
149
+ * writing a temp file and renaming it over the old one, so the inode the
150
+ * file watch is holding is the one that gets replaced, and further updates
151
+ * are never seen.
152
+ */
153
+ let watcher: FSWatcher | undefined
154
+ try {
155
+ watcher = watch(dirname(STATUS_PATH), (_event, changed) => {
156
+ if (!changed || basename(String(changed)) === basename(STATUS_PATH)) void refresh()
157
+ })
158
+ watcher.on("error", () => watcher?.close())
159
+ api.lifecycle.onDispose(() => watcher?.close())
160
+ debug("watching the status file")
161
+ } catch (error) {
162
+ // The directory may not exist yet, or the platform may not support
163
+ // watching it. The poll below covers both.
164
+ debug(`could not watch the status file (${error instanceof Error ? error.message : String(error)})`)
165
+ }
166
+
167
+ const timer = setInterval(() => void refresh(), POLL_MS)
168
+ api.lifecycle.onDispose(() => clearInterval(timer))
169
+
170
+ // Re-read immediately on session activity, so the panel is right at the
171
+ // moment someone looks at it rather than up to a poll interval later.
172
+ api.lifecycle.onDispose(api.event.on("session.updated", () => void refresh()))
173
+
174
+ /**
175
+ * The detail view, and the reason the `/tinfoil` command is gone. This runs
176
+ * entirely in the TUI: no message, no model turn, nothing added to the
177
+ * conversation and no context re-sent. The report text was rendered by the
178
+ * server half, so what it shows is what that process actually verified.
179
+ */
180
+ const showDetails = () => {
181
+ const current = view()
182
+ const lines =
183
+ current.kind === "unknown"
184
+ ? [
185
+ "Tinfoil status unavailable.",
186
+ "",
187
+ "No recent verdict was published by an opencode server process.",
188
+ "The provider plugin may not be loaded — see",
189
+ "https://tinfoil.sh/coding-agents",
190
+ ]
191
+ : current.status.report
192
+
193
+ /**
194
+ * Deliberately not `api.ui.DialogAlert`: it takes one `message` string
195
+ * and lays it out at whatever height that needs, so a verification
196
+ * document — 35 lines of steps, digests and keys — ran off the bottom of
197
+ * the terminal with no way to reach the rest. The body here is a focused
198
+ * `scrollbox`, which arrow keys, page keys and the mouse wheel all
199
+ * scroll.
200
+ *
201
+ * Also deliberately not wrapped in `api.ui.Dialog`: `dialog.replace`
202
+ * already renders its child inside that same overlay, and nesting a
203
+ * second one applies its `paddingTop: terminalHeight / 4` twice, which
204
+ * pushed the whole thing to the middle of the screen and off the bottom.
205
+ * Passing the body directly puts it where the command palette sits.
206
+ */
207
+ const theme = api.theme.current
208
+
209
+ /**
210
+ * Nothing in the overlay bounds a child's height — `dialog.setSize` picks
211
+ * a width (60/88/116 columns) and nothing else — so the viewport has to
212
+ * be sized here: what is left under that quarter-height offset, less the
213
+ * title, the footer and their margins.
214
+ */
215
+ const rows = api.renderer.terminalHeight
216
+ const viewportRows = Math.max(4, Math.min(lines.length, rows - Math.floor(rows / 4) - 7))
217
+
218
+ api.ui.dialog.replace(
219
+ () =>
220
+ jsx("box", {
221
+ flexDirection: "column",
222
+ paddingLeft: 2,
223
+ paddingRight: 2,
224
+ children: [
225
+ jsx("text", { fg: theme.text, children: "Tinfoil verification" }),
226
+ jsx("scrollbox", {
227
+ focused: true,
228
+ scrollY: true,
229
+ height: viewportRows,
230
+ marginTop: 1,
231
+ // One text per line rather than one blob with newlines: the
232
+ // scrollbox measures its content from child heights, and a
233
+ // single multi-line child reports one row and never scrolls.
234
+ children: lines.map((line) => jsx("text", { fg: theme.text, children: line })),
235
+ }),
236
+ jsx("text", {
237
+ fg: theme.textMuted,
238
+ marginTop: 1,
239
+ children: lines.length > viewportRows ? "↑/↓ scroll · esc close" : "esc close",
240
+ }),
241
+ ],
242
+ }) as never,
243
+ )
244
+ // After `replace`, not before: it resets the stack size to "medium" (60
245
+ // columns) on the way in, which wrapped every digest line in half.
246
+ api.ui.dialog.setSize("xlarge")
247
+ }
248
+
249
+ /**
250
+ * Registers `/tinfoil` plus the command-palette entry.
251
+ *
252
+ * `api.command.register` is marked deprecated in favour of
253
+ * `api.keymap.registerLayer({ commands, bindings })`, but as of opencode
254
+ * 1.18.27 the keymap route does not reach the command palette: a command
255
+ * registered that way is dispatchable yet invisible, and `ctrl+p` reports
256
+ * "No results" for it — with or without a keybinding. The legacy call is
257
+ * the only one that lists, and the only one that takes a `slash` name.
258
+ * Revisit when registerLayer grows palette support.
259
+ *
260
+ * Either entry point runs entirely in the TUI process: no message, no model
261
+ * turn, nothing added to the conversation and no context re-sent.
262
+ */
263
+ if (api.command?.register) {
264
+ api.lifecycle.onDispose(
265
+ api.command.register(() => [
266
+ {
267
+ title: "Tinfoil: verification details",
268
+ value: "tinfoil.details",
269
+ // No `description`: opencode's own commands set none, and the
270
+ // palette renders it inline after the title, which made this row
271
+ // twice as long as every other one.
272
+ category: "Plugin",
273
+ slash: { name: "tinfoil" },
274
+ onSelect: () => void showDetails(),
275
+ },
276
+ ]),
277
+ )
278
+ debug("registered /tinfoil and the palette entry")
279
+ } else {
280
+ debug("api.command.register unavailable; sidebar panel only")
281
+ }
282
+
283
+ /**
284
+ * Solid renders a component body once, and normally its compiler rewrites
285
+ * dynamic JSX expressions into getters the renderer can track. This file
286
+ * calls the runtime `jsx()` factory directly to avoid a build step, so
287
+ * there is no compiler and a `() => value` passed as a prop is merely
288
+ * stored as a function — read once, never again.
289
+ *
290
+ * The reactive boundary is therefore `children` on the outer box: a
291
+ * function child is an accessor Solid does track, and re-running it
292
+ * rebuilds the inner elements from plain, already-resolved values.
293
+ */
294
+ const TinfoilPanel = () =>
295
+ jsx("box", {
296
+ flexDirection: "column",
297
+ marginTop: 1,
298
+ children: () => {
299
+ const current = view()
300
+ const theme = api.theme.current
301
+ const headline =
302
+ current.kind === "verified"
303
+ ? `Tinfoil ${MARK_OK} encrypted`
304
+ : current.kind === "unverified"
305
+ ? `Tinfoil ${MARK_BAD} UNVERIFIED`
306
+ : current.kind === "unprotected"
307
+ ? `Tinfoil ${MARK_BAD} NOT PROTECTED`
308
+ : "Tinfoil · checking…"
309
+ const detail =
310
+ current.kind === "verified"
311
+ ? ` ${current.status.releaseTag ?? "unknown"} · ${shortHash(current.status.releaseDigest)}`
312
+ : current.kind === "unverified"
313
+ ? " requests blocked"
314
+ : current.kind === "unprotected"
315
+ ? " not routed through Tinfoil"
316
+ : " waiting for attestation"
317
+ const fg =
318
+ current.kind === "verified" ? theme.success : current.kind === "unknown" ? theme.textMuted : theme.error
319
+
320
+ return [jsx("text", { fg, children: headline }), jsx("text", { fg: theme.textMuted, children: detail })]
321
+ },
322
+ })
323
+
324
+ api.slots.register({
325
+ slots: {
326
+ sidebar_content: () => TinfoilPanel() as never,
327
+ },
328
+ })
329
+
330
+ debug("sidebar panel registered")
331
+ },
332
+ }
333
+
334
+ export default plugin