opencode-thrum-pro 0.1.1 → 0.1.2
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/assets/commands/thrum-_stamp-protocol.md +65 -0
- package/assets/skills/brainstorm-steward/SKILL.md +15 -0
- package/assets/skills/choosing-subagent-models/SKILL.md +19 -11
- package/assets/skills/conversation-only-context/SKILL.md +186 -0
- package/assets/skills/coordinator-dispatching-work/SKILL.md +28 -8
- package/assets/skills/coordinator-hotpath-merge-gate/SKILL.md +68 -0
- package/assets/skills/coordinator-merging-code/SKILL.md +24 -0
- package/assets/skills/coordinator-running-brainstorm-cycles/SKILL.md +11 -7
- package/assets/skills/efficient-multi-agent-research/SKILL.md +7 -6
- package/assets/skills/persistent-watcher-archetype/SKILL.md +68 -9
- package/assets/skills/persistent-watcher-archetype/resources/thrum-capture-fallback.sh +123 -0
- package/assets/skills/persistent-watcher-archetype/resources/thrum-watch-pane-capture.sh +379 -0
- package/assets/skills/prime-agent/SKILL.md +2 -4
- package/assets/skills/project-setup/SKILL.md +118 -21
- package/assets/skills/update-queue/SKILL.md +3 -1
- package/assets/thrum_allowlist.json +34 -0
- package/dist/allowlist-sync.test.d.ts +1 -0
- package/dist/allowlist-sync.test.js +44 -0
- package/dist/index.js +24 -1
- package/dist/installer.d.ts +29 -0
- package/dist/installer.js +356 -1
- package/dist/installer.permissions.test.d.ts +1 -0
- package/dist/installer.permissions.test.js +602 -0
- package/dist/permission-fixture.integration.test.d.ts +1 -0
- package/dist/permission-fixture.integration.test.js +506 -0
- package/package.json +3 -2
package/dist/installer.js
CHANGED
|
@@ -15,7 +15,7 @@ function getPluginVersion() {
|
|
|
15
15
|
return "0.0.0";
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
-
function getConfigDir() {
|
|
18
|
+
export function getConfigDir() {
|
|
19
19
|
const xdg = process.env.XDG_CONFIG_HOME;
|
|
20
20
|
if (xdg)
|
|
21
21
|
return path.join(xdg, "opencode");
|
|
@@ -84,3 +84,358 @@ export async function installAssets(ctx) {
|
|
|
84
84
|
fs.writeFileSync(versionPath, currentVersion);
|
|
85
85
|
await log(`opencode-thrum v${currentVersion} assets installed`);
|
|
86
86
|
}
|
|
87
|
+
const ALLOWLIST_ASSET_PATH = path.join(ASSETS_DIR, "thrum_allowlist.json");
|
|
88
|
+
// bashExactPatternFor renders the bare-command form of a canonical
|
|
89
|
+
// command_patterns entry (e.g. "thrum inbox" -> "thrum inbox"), with no
|
|
90
|
+
// trailing glob at all. OpenCode's permission matcher is documented simple-
|
|
91
|
+
// wildcard matching (`*` = zero-or-more of any char, `?` = exactly one,
|
|
92
|
+
// everything else literal — https://opencode.ai/docs/permissions/,
|
|
93
|
+
// verified manually) with NO built-in word-boundary behavior: `*` matches
|
|
94
|
+
// straight through a word boundary, so a bare `${commandPattern}*` key (the
|
|
95
|
+
// round-1 shape) would also allow "thrum inboxx-evil" under the "thrum
|
|
96
|
+
// inbox" grant. This exact-match key is the half of the two-key pair (see
|
|
97
|
+
// bashPatternFor below) that covers invoking the command with NO arguments
|
|
98
|
+
// at all, where a glob suffix would otherwise require a literal trailing
|
|
99
|
+
// character to exist.
|
|
100
|
+
export function bashExactPatternFor(commandPattern) {
|
|
101
|
+
return commandPattern;
|
|
102
|
+
}
|
|
103
|
+
// bashPatternFor renders the with-arguments form of a canonical
|
|
104
|
+
// command_patterns entry (e.g. "thrum inbox" -> "thrum inbox *"). The
|
|
105
|
+
// literal SPACE before the glob is the word-boundary fix (the allowlist effort round
|
|
106
|
+
// 2, finding 7): it requires a real space character to follow the command
|
|
107
|
+
// prefix before the glob can start matching, so "thrum inbox *" matches
|
|
108
|
+
// "thrum inbox --unread" but does NOT match "thrum inboxx-evil" (no space
|
|
109
|
+
// after "inbox" in that string) or "thrum inboxx" (same reason). Round 1's
|
|
110
|
+
// bare `${commandPattern}*` (no space) is what let those adjacent-command
|
|
111
|
+
// false positives through — see the negative-control test added alongside
|
|
112
|
+
// this fix.
|
|
113
|
+
export function bashPatternFor(commandPattern) {
|
|
114
|
+
return `${commandPattern} *`;
|
|
115
|
+
}
|
|
116
|
+
// readPatternFor renders one canonical read_paths entry (e.g. ".thrum")
|
|
117
|
+
// into an OpenCode read/external_directory glob key admitting the whole
|
|
118
|
+
// subtree.
|
|
119
|
+
//
|
|
120
|
+
// LEADING-SLASH STRIP — measured against the real `opencode` binary
|
|
121
|
+
// (the allowlist effort round 4, by-effect fixture): OpenCode's permission matcher
|
|
122
|
+
// normalizes an absolute candidate path with its leading "/" removed before
|
|
123
|
+
// comparing against configured patterns. A literal "/private/tmp/**" key
|
|
124
|
+
// NEVER matched a read of "/private/tmp/x" in the real oracle (fell through
|
|
125
|
+
// to OpenCode's own built-in default every time, regardless of "*" default
|
|
126
|
+
// or key order); the identical pattern with the leading slash stripped,
|
|
127
|
+
// "private/tmp/**", matched correctly both ways — ALLOW under the granted
|
|
128
|
+
// path, DENY on a sibling root. This was previously unverified: no by-effect
|
|
129
|
+
// test exercised an absolute-path read pattern before this round, so this
|
|
130
|
+
// same defect silently affected opencodeGlobalReadPaths's configDir-derived
|
|
131
|
+
// DIRECTORY patterns below (${configDir}/skills, ${configDir}/commands)
|
|
132
|
+
// since they are rendered through this same function. It is a no-op for
|
|
133
|
+
// the already-relative canonical read_paths entries (e.g. ".thrum"), which
|
|
134
|
+
// never had a leading slash to strip.
|
|
135
|
+
//
|
|
136
|
+
// The THIRD opencodeGlobalReadPaths entry, ${configDir}/opencode.json, is a
|
|
137
|
+
// FILE, not a directory — it is rendered through the sibling
|
|
138
|
+
// readFilePatternFor (same strip, no "/**" suffix), not this function; see
|
|
139
|
+
// that function's doc comment for a round-4-review finding where this file
|
|
140
|
+
// entry was initially missed and stayed silently broken one round after
|
|
141
|
+
// this fix landed for the directory entries.
|
|
142
|
+
export function readPatternFor(readPath) {
|
|
143
|
+
const normalized = stripLeadingSlash(readPath);
|
|
144
|
+
return `${normalized}/**`;
|
|
145
|
+
}
|
|
146
|
+
// stripLeadingSlash removes a leading "/" from an absolute path so it
|
|
147
|
+
// matches OpenCode's real permission-matcher normalization (see
|
|
148
|
+
// readPatternFor's doc comment above). Shared by readPatternFor (directory
|
|
149
|
+
// glob entries) and readFilePatternFor (exact-file entries, which must NOT
|
|
150
|
+
// get a "/**" suffix — a file is not a tree) so both render through the
|
|
151
|
+
// SAME normalization instead of two independently-maintained copies of it.
|
|
152
|
+
function stripLeadingSlash(p) {
|
|
153
|
+
return p.startsWith("/") ? p.slice(1) : p;
|
|
154
|
+
}
|
|
155
|
+
// resolveHomePath expands a leading "~" (bare, or "~/...") into an absolute
|
|
156
|
+
// path rooted at homeDir, otherwise returns the path unchanged. Takes homeDir
|
|
157
|
+
// as an explicit parameter (default os.homedir()) rather than calling
|
|
158
|
+
// os.homedir() inline so callers (tests, the by-effect fixture) can resolve
|
|
159
|
+
// against an isolated fake home without mutating process.env.HOME globally —
|
|
160
|
+
// mirrors how installPermissions/mergePermissions already take configDir as
|
|
161
|
+
// an explicit parameter for the identical reason.
|
|
162
|
+
//
|
|
163
|
+
// Every OTHER absolute-path grant this file renders (opencodeGlobalReadPaths,
|
|
164
|
+
// owner_authorized_exceptions' /private/tmp) is either already absolute or
|
|
165
|
+
// derived from getConfigDir(), which itself resolves os.homedir() and never
|
|
166
|
+
// emits a literal "~" (confirmed pattern in this codebase — see getConfigDir
|
|
167
|
+
// above). global_read_paths entries are the first canonical-allowlist value
|
|
168
|
+
// to arrive as a literal "~"-prefixed string, so this is the one call site
|
|
169
|
+
// that needs to expand it before handing the result to readPatternFor —
|
|
170
|
+
// never emit a literal, unexpanded "~" into a rendered permission key.
|
|
171
|
+
export function resolveHomePath(p, homeDir = os.homedir()) {
|
|
172
|
+
if (p === "~")
|
|
173
|
+
return homeDir;
|
|
174
|
+
if (p.startsWith("~/"))
|
|
175
|
+
return path.join(homeDir, p.slice(2));
|
|
176
|
+
return p;
|
|
177
|
+
}
|
|
178
|
+
// readFilePatternFor renders an absolute path to a single FILE (not a
|
|
179
|
+
// directory subtree) into an OpenCode read-permission key: the same
|
|
180
|
+
// leading-slash strip as readPatternFor, but no "/**" suffix — round-4
|
|
181
|
+
// review finding (cq, by-effect-confirmed): opencodeGlobalReadPaths's
|
|
182
|
+
// ${configDir}/opencode.json entry was rendered as a raw, unstripped path
|
|
183
|
+
// and so was STILL silently broken by the same defect readPatternFor's
|
|
184
|
+
// fix addressed everywhere else. Confirmed by-effect (real opencode
|
|
185
|
+
// binary) that only the stripped form renders correctly.
|
|
186
|
+
export function readFilePatternFor(filePath) {
|
|
187
|
+
return stripLeadingSlash(filePath);
|
|
188
|
+
}
|
|
189
|
+
// loadAllowlist reads the canonical allowlist JSON asset bundled with this
|
|
190
|
+
// plugin (opencode-plugin/assets/thrum_allowlist.json — a byte-for-byte
|
|
191
|
+
// copy of internal/permissions/thrum_allowlist.json, kept in sync by
|
|
192
|
+
// scripts/sync-skills.sh's sync_opencode step; see that script's comment
|
|
193
|
+
// for why a copy is needed instead of a live cross-language import).
|
|
194
|
+
export function loadAllowlist(assetPath = ALLOWLIST_ASSET_PATH) {
|
|
195
|
+
const raw = fs.readFileSync(assetPath, "utf8");
|
|
196
|
+
return JSON.parse(raw);
|
|
197
|
+
}
|
|
198
|
+
// OPENCODE_WILDCARD_KEY is the object-map form's documented catch-all key
|
|
199
|
+
// (https://opencode.ai/docs/permissions/, verified manually: "A common
|
|
200
|
+
// pattern is to put the catch-all '*' rule first, and more specific rules
|
|
201
|
+
// after it" — the schema's object-map form has no dedicated default-key
|
|
202
|
+
// field; "*" IS the default-key convention). Rule evaluation is
|
|
203
|
+
// last-match-wins over the map's key order, and a JS object's string keys
|
|
204
|
+
// iterate in insertion order — so this key must always be inserted FIRST,
|
|
205
|
+
// before any thrum-specific pattern, or a later "*" insertion would win and
|
|
206
|
+
// silently shadow every thrum allow entry.
|
|
207
|
+
const OPENCODE_WILDCARD_KEY = "*";
|
|
208
|
+
// preserveBareScalarAsDefault turns a pre-existing BARE-STRING permission
|
|
209
|
+
// action (the schema's PermissionActionConfig form — a blanket default for
|
|
210
|
+
// every command, e.g. permission.bash: "deny") into the seed of an object
|
|
211
|
+
// map that keeps that blanket action as the map's "*" default, so a
|
|
212
|
+
// caller's stricter-than-OpenCode's-own-default posture ("deny"/"ask") is
|
|
213
|
+
// never silently widened by our overlay (the allowlist effort round 2, finding 3).
|
|
214
|
+
// Confirmed against the schema before writing this: the object-map form
|
|
215
|
+
// DOES support a "*" default key (see OPENCODE_WILDCARD_KEY above), so this
|
|
216
|
+
// is the "schema supports a default key" branch, not the leave-untouched
|
|
217
|
+
// fallback — the fallback is unnecessary here.
|
|
218
|
+
function preserveBareScalarAsDefault(existing) {
|
|
219
|
+
if (typeof existing === "string")
|
|
220
|
+
return { [OPENCODE_WILDCARD_KEY]: existing };
|
|
221
|
+
if (existing && typeof existing === "object")
|
|
222
|
+
return { ...existing };
|
|
223
|
+
return {};
|
|
224
|
+
}
|
|
225
|
+
// mergePermissions returns a NEW config object with permission.bash and
|
|
226
|
+
// permission.read populated from the allowlist (plus any already-rendered
|
|
227
|
+
// extraReadPatterns, e.g. OpenCode's own global skills/commands subtree —
|
|
228
|
+
// see opencodeGlobalReadPaths), preserving every other key (including any
|
|
229
|
+
// pre-existing permission.* sub-keys such as permission.edit) untouched.
|
|
230
|
+
// Pure function, no I/O — kept separate from installPermissions so it's
|
|
231
|
+
// directly unit-testable.
|
|
232
|
+
//
|
|
233
|
+
// If an existing permission.bash/read is a bare action string (e.g. "ask"
|
|
234
|
+
// or "deny") rather than an object map, that string is a blanket default
|
|
235
|
+
// the caller set deliberately for EVERY command/path, and is preserved
|
|
236
|
+
// as-is via preserveBareScalarAsDefault — never dropped, never silently
|
|
237
|
+
// widened by treating the overlay as a full replacement.
|
|
238
|
+
export function mergePermissions(config, allowlist, extraReadPatterns = [], homeDir = os.homedir()) {
|
|
239
|
+
const merged = { ...config };
|
|
240
|
+
const existingPermission = merged.permission ?? {};
|
|
241
|
+
const permission = { ...existingPermission };
|
|
242
|
+
const bash = preserveBareScalarAsDefault(existingPermission.bash);
|
|
243
|
+
for (const commandPattern of allowlist.command_patterns) {
|
|
244
|
+
bash[bashExactPatternFor(commandPattern)] = "allow";
|
|
245
|
+
bash[bashPatternFor(commandPattern)] = "allow";
|
|
246
|
+
}
|
|
247
|
+
const read = preserveBareScalarAsDefault(existingPermission.read);
|
|
248
|
+
for (const readPath of allowlist.read_paths) {
|
|
249
|
+
read[readPatternFor(readPath)] = "allow";
|
|
250
|
+
}
|
|
251
|
+
for (const pattern of extraReadPatterns) {
|
|
252
|
+
read[pattern] = "allow";
|
|
253
|
+
}
|
|
254
|
+
// OWNER-AUTHORIZED EXCEPTION — /private/tmp (owner ruling, watcher-
|
|
255
|
+
// recovery P0; scope CONFIRMED all-runtimes by the fleet coordinator the same
|
|
256
|
+
// day — owner's wording was "global configs for all agents", the
|
|
257
|
+
// operational-artifact class is runtime-independent): the canonical
|
|
258
|
+
// allowlist's owner_authorized_exceptions grants EVERY supported runtime
|
|
259
|
+
// broad READ access to /private/tmp/* DESPITE it being a world-writable
|
|
260
|
+
// directory — explicitly ruled acceptable by the owner for
|
|
261
|
+
// coordinator/watcher operational artifacts. This is a DELIBERATE
|
|
262
|
+
// exception, not a template for widening elsewhere: it grants READ/ACCESS
|
|
263
|
+
// ONLY — never execute, never shell-interpolation, never a sibling root
|
|
264
|
+
// like /private or /private/tmpfoo, never write/delete. See
|
|
265
|
+
// internal/permissions/thrum_allowlist.json's own "OWNER-AUTHORIZED
|
|
266
|
+
// EXCEPTION" comment for the full ruling this quotes from. Rendered
|
|
267
|
+
// through the same readPatternFor() glob as canonical read_paths (e.g.
|
|
268
|
+
// "/private/tmp" -> "/private/tmp/**") so it goes through the identical
|
|
269
|
+
// preserveBareScalarAsDefault-aware merge machinery above — never a
|
|
270
|
+
// bypass path.
|
|
271
|
+
const ownerExceptions = allowlist.owner_authorized_exceptions?.opencode ?? [];
|
|
272
|
+
for (const exceptionPath of ownerExceptions) {
|
|
273
|
+
read[readPatternFor(exceptionPath)] = "allow";
|
|
274
|
+
}
|
|
275
|
+
// GLOBAL READ PATHS — ~/.local/bin (owner ruling, consolidated round):
|
|
276
|
+
// the canonical allowlist's global_read_paths grants OpenCode (like Claude
|
|
277
|
+
// and Codex) READ access to the thrum-binary install directory, so
|
|
278
|
+
// PATH-resolved "thrum" can actually execute under the runtime's sandbox.
|
|
279
|
+
// Rendered through the SAME readPatternFor() glob and the SAME
|
|
280
|
+
// preserveBareScalarAsDefault-aware merge machinery as read_paths and
|
|
281
|
+
// owner_authorized_exceptions above — never a bypass path. Each entry is
|
|
282
|
+
// resolved through resolveHomePath() BEFORE readPatternFor() so a literal
|
|
283
|
+
// "~" is expanded to the real absolute path first (every other absolute-
|
|
284
|
+
// path grant in this file is already fully expanded before rendering; see
|
|
285
|
+
// resolveHomePath's doc comment for why this is the one call site that
|
|
286
|
+
// needs the expansion step). Optional chaining: absent on an older/
|
|
287
|
+
// pre-round canonical asset, and a prior round deliberately left OpenCode
|
|
288
|
+
// with no entries here at all (see AllowlistSource's global_read_paths
|
|
289
|
+
// doc comment) — both no-op cleanly to an empty array.
|
|
290
|
+
const globalReadPaths = allowlist.global_read_paths?.opencode ?? [];
|
|
291
|
+
for (const rawPath of globalReadPaths) {
|
|
292
|
+
read[readPatternFor(resolveHomePath(rawPath, homeDir))] = "allow";
|
|
293
|
+
}
|
|
294
|
+
permission.bash = bash;
|
|
295
|
+
permission.read = read;
|
|
296
|
+
merged.permission = permission;
|
|
297
|
+
return merged;
|
|
298
|
+
}
|
|
299
|
+
// readConfig is split out from installPermissions so tests can exercise
|
|
300
|
+
// mergePermissions in isolation without touching disk, and so
|
|
301
|
+
// installPermissions itself stays a thin, obviously-idempotent I/O shell.
|
|
302
|
+
//
|
|
303
|
+
// Mirrors the Go-side hookmerge Load(): an empty config is
|
|
304
|
+
// returned ONLY when the file genuinely does not exist (Node's ENOENT,
|
|
305
|
+
// matching Go's os.IsNotExist). Any other failure — malformed JSON,
|
|
306
|
+
// permission-denied read, or any other I/O error — is THROWN, never
|
|
307
|
+
// swallowed into {}. Swallowing a parse error here previously meant a
|
|
308
|
+
// user's hand-edited-but-broken global opencode.json (e.g. a stray comma)
|
|
309
|
+
// was treated as "absent" and silently overwritten by installPermissions()
|
|
310
|
+
// with a fresh config containing only thrum's permission block, discarding
|
|
311
|
+
// every other setting the user had (the allowlist effort landing-blocker).
|
|
312
|
+
function readConfig(configPath) {
|
|
313
|
+
let raw;
|
|
314
|
+
try {
|
|
315
|
+
raw = fs.readFileSync(configPath, "utf8");
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
if (err.code === "ENOENT")
|
|
319
|
+
return {};
|
|
320
|
+
throw new Error(`failed to read OpenCode config at ${configPath}: ${err.message}`, { cause: err });
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
return JSON.parse(raw);
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
throw new Error(`failed to parse OpenCode config at ${configPath} as JSON — it will be left untouched: ${err.message}`, { cause: err });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Split out from installPermissions (like readConfig) so tests can exercise
|
|
330
|
+
// the crash-safety contract directly.
|
|
331
|
+
//
|
|
332
|
+
// Pass-3 B3 — ATOMIC by construction. The merge target is the user's REAL
|
|
333
|
+
// global opencode.json, and readConfig deliberately throws on malformed JSON
|
|
334
|
+
// (never treats it as absent), so a half-written file from a crash mid-write
|
|
335
|
+
// would brick every future install AND OpenCode's own config load. The write
|
|
336
|
+
// is therefore:
|
|
337
|
+
// 1. fully serialized in memory FIRST (a non-serializable config throws
|
|
338
|
+
// before anything on disk is touched);
|
|
339
|
+
// 2. written to a temp file in the SAME directory as the target (same
|
|
340
|
+
// filesystem, so the final rename is atomic — never a cross-device
|
|
341
|
+
// EXDEV rename), with the existing file's permission mode preserved on
|
|
342
|
+
// overwrite (a fresh file gets 0o600 — narrower, never wider, than the
|
|
343
|
+
// old writeFileSync default on a fresh create);
|
|
344
|
+
// 3. fsync'd before the rename, so the bytes are durable before the name
|
|
345
|
+
// flips (a crash after the rename can then only leave the old OR the
|
|
346
|
+
// new complete file — never a truncated or mixed one);
|
|
347
|
+
// 4. swapped in by rename (atomic; the original inode is never truncated
|
|
348
|
+
// in place) — and on any failure the temp file is unlinked before the
|
|
349
|
+
// error propagates, so zero temp residue is ever left behind.
|
|
350
|
+
export function writeConfig(configPath, config) {
|
|
351
|
+
const dir = path.dirname(configPath);
|
|
352
|
+
const serialized = `${JSON.stringify(config, null, 2)}\n`;
|
|
353
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
354
|
+
const tmpPath = path.join(dir, `.${path.basename(configPath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
355
|
+
let mode = 0o600;
|
|
356
|
+
try {
|
|
357
|
+
mode = fs.statSync(configPath).mode & 0o777;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
// Target does not exist yet — keep the restrictive fresh-file default.
|
|
361
|
+
}
|
|
362
|
+
let fd;
|
|
363
|
+
try {
|
|
364
|
+
fd = fs.openSync(tmpPath, "w", mode);
|
|
365
|
+
fs.writeSync(fd, serialized);
|
|
366
|
+
fs.fsyncSync(fd);
|
|
367
|
+
fs.closeSync(fd);
|
|
368
|
+
fd = undefined;
|
|
369
|
+
fs.renameSync(tmpPath, configPath);
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
if (fd !== undefined) {
|
|
373
|
+
try {
|
|
374
|
+
fs.closeSync(fd);
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// best-effort close on the error path — the original close error
|
|
378
|
+
// (if any) matters more than this one
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
fs.unlinkSync(tmpPath);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
// best-effort residue cleanup; never mask the real failure
|
|
386
|
+
}
|
|
387
|
+
throw err;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// opencodeGlobalReadPaths returns the already-rendered read-glob patterns
|
|
391
|
+
// for OpenCode's OWN global install subtree under configDir: exactly the
|
|
392
|
+
// ${configDir}/skills and ${configDir}/commands trees installAssets() (see
|
|
393
|
+
// above in this file) writes to, plus the global opencode.json config file
|
|
394
|
+
// itself. This is the allowlist effort round 2 finding 1: the canonical allowlist's
|
|
395
|
+
// read_paths are all PROJECT-relative (.thrum, .claude/skills, etc) and
|
|
396
|
+
// cannot express OpenCode's absolute, outside-the-project global config
|
|
397
|
+
// location — a watcher/orchestrator reading its own installed OpenCode
|
|
398
|
+
// skill/command files needs read access to THAT subtree too, in ADDITION
|
|
399
|
+
// to (never instead of) the canonical project-relative set.
|
|
400
|
+
//
|
|
401
|
+
// Deliberately scoped to configDir and its known children only — no
|
|
402
|
+
// broader home-dir grant. Resolved via getConfigDir() (or the configDir
|
|
403
|
+
// passed to installPermissions), never hardcoded, so it tracks
|
|
404
|
+
// XDG_CONFIG_HOME/HOME like the rest of this file.
|
|
405
|
+
export function opencodeGlobalReadPaths(configDir) {
|
|
406
|
+
return [
|
|
407
|
+
readPatternFor(path.join(configDir, "skills")),
|
|
408
|
+
readPatternFor(path.join(configDir, "commands")),
|
|
409
|
+
readFilePatternFor(path.join(configDir, "opencode.json")),
|
|
410
|
+
];
|
|
411
|
+
}
|
|
412
|
+
// installPermissions merges the canonical thrum command/read-path allowlist
|
|
413
|
+
// — plus OpenCode's own global skills/commands/config read grant (see
|
|
414
|
+
// opencodeGlobalReadPaths) — into the user's GLOBAL OpenCode config
|
|
415
|
+
// (${configDir}/opencode.json). Idempotent: re-running with unchanged
|
|
416
|
+
// inputs (allowlist + prior config) produces byte-identical output, since
|
|
417
|
+
// mergePermissions is a pure deterministic function of its inputs and
|
|
418
|
+
// readConfig/writeConfig round-trip via JSON.stringify with a fixed
|
|
419
|
+
// 2-space indent.
|
|
420
|
+
//
|
|
421
|
+
// If the existing config is present but malformed (unreadable or invalid
|
|
422
|
+
// JSON), readConfig() throws rather than returning {} — this function does
|
|
423
|
+
// NOT catch that error, so mergePermissions/writeConfig are never reached
|
|
424
|
+
// and the on-disk file is left byte-for-byte untouched. The caller (see
|
|
425
|
+
// index.ts) is responsible for catching and logging that error without
|
|
426
|
+
// crashing plugin bootstrap.
|
|
427
|
+
// homeDir defaults to os.homedir() and is used ONLY to resolve
|
|
428
|
+
// global_read_paths' "~"-prefixed entries (see resolveHomePath); it is
|
|
429
|
+
// independent of configDir (OpenCode's own global config directory, which
|
|
430
|
+
// may live under XDG_CONFIG_HOME and not under homeDir at all). Threaded
|
|
431
|
+
// through as an explicit parameter — never read inline via os.homedir()
|
|
432
|
+
// inside mergePermissions' caller chain — so tests and the by-effect
|
|
433
|
+
// fixture can resolve against an isolated fake home without mutating
|
|
434
|
+
// process.env.HOME globally.
|
|
435
|
+
export async function installPermissions(configDir = getConfigDir(), homeDir = os.homedir()) {
|
|
436
|
+
const configPath = path.join(configDir, "opencode.json");
|
|
437
|
+
const existing = readConfig(configPath);
|
|
438
|
+
const allowlist = loadAllowlist();
|
|
439
|
+
const merged = mergePermissions(existing, allowlist, opencodeGlobalReadPaths(configDir), homeDir);
|
|
440
|
+
writeConfig(configPath, merged);
|
|
441
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|