Compare commits

..
Author SHA1 Message Date
Jack 6177759ca8 fix(app): align usage chart with local timezone 2026-04-25 17:31:41 +08:00
29 changed files with 221 additions and 390 deletions
+23 -24
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -269,7 +269,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -298,7 +298,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -314,7 +314,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.25",
"version": "1.14.24",
"bin": {
"opencode": "./bin/opencode",
},
@@ -367,8 +367,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.1.103",
"@opentui/solid": "0.1.103",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -403,7 +403,7 @@
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.1",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "catalog:",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
"semver": "^7.6.3",
@@ -460,15 +460,15 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.1.103",
"@opentui/solid": "0.1.103",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -495,7 +495,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -510,7 +510,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "1.14.25",
"version": "1.14.24",
"bin": {
"opencode": "./bin/opencode",
},
@@ -534,7 +534,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -569,7 +569,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -618,7 +618,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -678,8 +678,8 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.1.103",
"@opentui/solid": "0.1.103",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@solid-primitives/storage": "4.3.3",
@@ -708,7 +708,6 @@
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"opentui-spinner": "0.0.6",
"remeda": "2.26.0",
"remend": "1.3.0",
"semver": "7.7.4",
+2 -3
View File
@@ -34,8 +34,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.1.103",
"@opentui/solid": "0.1.103",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -46,7 +46,6 @@
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.18",
"opentui-spinner": "0.0.6",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.25",
"version": "1.14.24",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.25",
"version": "1.14.24",
"type": "module",
"license": "MIT",
"scripts": {
@@ -27,12 +27,16 @@ Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, L
async function getCosts(workspaceID: string, year: number, month: number) {
"use server"
return withActor(async () => {
const startDate = new Date(year, month, 1)
const endDate = new Date(year, month + 1, 1)
// Fetch a UTC buffer so client-side local dates at month boundaries are not dropped.
const startDate = new Date(Date.UTC(year, month, 1))
startDate.setUTCDate(startDate.getUTCDate() - 1)
const endDate = new Date(Date.UTC(year, month + 1, 1))
endDate.setUTCDate(endDate.getUTCDate() + 1)
const hourBucket = sql<number>`FLOOR(UNIX_TIMESTAMP(${UsageTable.timeCreated}) / 3600)`
const usageData = await Database.use((tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`,
hourBucket: hourBucket.as("hour_bucket"),
model: UsageTable.model,
totalCost: sum(UsageTable.cost),
keyId: UsageTable.keyID,
@@ -47,7 +51,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
),
)
.groupBy(
sql`DATE(${UsageTable.timeCreated})`,
hourBucket,
UsageTable.model,
UsageTable.keyID,
sql`JSON_EXTRACT(${UsageTable.enrichment}, '$.plan')`,
@@ -55,6 +59,7 @@ async function getCosts(workspaceID: string, year: number, month: number) {
.then((x) =>
x.map((r) => ({
...r,
hourBucket: Number(r.hourBucket),
totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
plan: r.plan as "sub" | "lite" | "byok" | null,
})),
@@ -125,15 +130,13 @@ function getModelColor(model: string): string {
}
function formatDateLabel(dateStr: string): string {
const date = new Date()
const [y, m, d] = dateStr.split("-").map(Number)
date.setFullYear(y)
date.setMonth(m - 1)
date.setDate(d)
date.setHours(0, 0, 0, 0)
const month = date.toLocaleDateString(undefined, { month: "short" })
const day = date.getUTCDate().toString().padStart(2, "0")
return `${month} ${day}`
const [year, month, day] = dateStr.split("-").map(Number)
const date = new Date(year, month - 1, day)
return `${date.toLocaleDateString(undefined, { month: "short" })} ${day.toString().padStart(2, "0")}`
}
function formatDateKey(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
}
function addOpacityToColor(color: string, opacity: number): string {
@@ -179,17 +182,24 @@ export function GraphSection() {
const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
const getModels = createMemo(() => {
if (!store.data?.usage) return []
return Array.from(new Set(store.data.usage.map((row) => row.model))).sort()
})
const getDates = createMemo(() => {
const daysInMonth = new Date(store.year, store.month + 1, 0).getDate()
return Array.from({ length: daysInMonth }, (_, i) => {
const date = new Date(store.year, store.month, i + 1)
return date.toISOString().split("T")[0]
})
const dates = Array.from({ length: daysInMonth }, (_, i) => formatDateKey(new Date(store.year, store.month, i + 1)))
const dateSet = new Set(dates)
return {
dates,
dateSet,
}
})
const getUsageForMonth = createMemo(() => {
if (!store.data?.usage) return []
const dateSet = getDates().dateSet
return store.data.usage.filter((row) => dateSet.has(formatDateKey(new Date(row.hourBucket * 3600 * 1000))))
})
const getModels = createMemo(() => {
return Array.from(new Set(getUsageForMonth().map((row) => row.model))).sort()
})
const getKeyName = (keyID: string | null): string => {
@@ -205,9 +215,9 @@ export function GraphSection() {
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
const chartConfig = createMemo((): ChartConfiguration | null => {
const data = store.data
const dates = getDates()
if (!data?.usage?.length) return null
const dates = getDates().dates
const usage = getUsageForMonth()
if (usage.length === 0) return null
store.colorScheme
const styles = getComputedStyle(document.documentElement)
@@ -229,11 +239,12 @@ export function GraphSection() {
dailyDataLite.set(dateKey, new Map())
}
data.usage
usage
.filter((row) => (store.key ? row.keyId === store.key : true))
.forEach((row) => {
const dateKey = formatDateKey(new Date(row.hourBucket * 3600 * 1000))
const targetMap = row.plan === "sub" ? dailyDataSub : row.plan === "lite" ? dailyDataLite : dailyDataRegular
const dayMap = targetMap.get(row.date)
const dayMap = targetMap.get(dateKey)
if (!dayMap) return
dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
})
@@ -286,6 +297,8 @@ export function GraphSection() {
}),
]
if (datasets.length === 0) return null
return {
type: "bar",
data: {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.25",
"version": "1.14.24",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.25",
"version": "1.14.24",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.25",
"version": "1.14.24",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.25",
"version": "1.14.24",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.25",
"version": "1.14.24",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.25",
"version": "1.14.24",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.25"
version = "1.14.24"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.25/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.24/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.25/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.24/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.25/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.24/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.25/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.24/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.25/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.24/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.25",
"version": "1.14.24",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.25",
"version": "1.14.24",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -124,8 +124,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.1.103",
"@opentui/solid": "0.1.103",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -160,7 +160,7 @@
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.1",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "catalog:",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
"semver": "^7.6.3",
+12 -35
View File
@@ -1,7 +1,6 @@
export * as ConfigPermission from "./permission"
import { Schema, SchemaGetter } from "effect"
import z from "zod"
import { ZodOverride, zod } from "@/util/effect-zod"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Action = Schema.Literals(["ask", "allow", "deny"])
@@ -19,9 +18,17 @@ export const Rule = Schema.Union([Action, Object])
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Rule = Schema.Schema.Type<typeof Rule>
// Known permission keys get explicit types in the Effect schema for generated
// docs/types. Runtime config parsing uses `InfoZod` below so user key order is
// preserved for permission precedence.
// Known permission keys get explicit types — most are full Rule (either a
// single Action or a per-pattern object), but a handful of tools take no
// sub-target patterns and are Action-only. Unknown keys fall through the
// Record rest signature as Rule.
//
// StructWithRest canonicalises key order on decode (known first, then rest),
// which used to require the `__originalKeys` preprocess hack because
// `Permission.fromConfig` depended on the user's insertion order. That
// dependency is gone — `fromConfig` now sorts top-level keys so wildcard
// permissions come before specifics, making the final precedence
// order-independent.
const InputObject = Schema.StructWithRest(
Schema.Struct({
read: Schema.optional(Rule),
@@ -53,35 +60,6 @@ const InputSchema = Schema.Union([Action, InputObject])
const normalizeInput = (input: Schema.Schema.Type<typeof InputSchema>): Schema.Schema.Type<typeof InputObject> =>
typeof input === "string" ? { "*": input } : input
const InfoZod = z
.union([
zod(Action),
z.intersection(
z.record(z.string(), zod(Rule)),
z
.object({
read: zod(Rule).optional(),
edit: zod(Rule).optional(),
glob: zod(Rule).optional(),
grep: zod(Rule).optional(),
list: zod(Rule).optional(),
bash: zod(Rule).optional(),
task: zod(Rule).optional(),
external_directory: zod(Rule).optional(),
todowrite: zod(Action).optional(),
question: zod(Action).optional(),
webfetch: zod(Action).optional(),
websearch: zod(Action).optional(),
codesearch: zod(Action).optional(),
lsp: zod(Rule).optional(),
doom_loop: zod(Action).optional(),
skill: zod(Rule).optional(),
})
.catchall(zod(Rule)),
),
])
.transform(normalizeInput)
export const Info = InputSchema.pipe(
Schema.decodeTo(InputObject, {
decode: SchemaGetter.transform(normalizeInput),
@@ -92,7 +70,6 @@ export const Info = InputSchema.pipe(
}),
)
.annotate({ identifier: "PermissionConfig" })
.annotate({ [ZodOverride]: InfoZod })
.pipe(
// Walker already emits the decodeTo transform into the derived zod (see
// `encoded()` in effect-zod.ts), so just expose that directly.
+11 -1
View File
@@ -288,8 +288,18 @@ function expand(pattern: string): string {
}
export function fromConfig(permission: ConfigPermission.Info) {
// Sort top-level keys so wildcard permissions (`*`, `mcp_*`) come before
// specific ones. Combined with `findLast` in evaluate(), this gives the
// intuitive semantic "specific tool rules override the `*` fallback"
// regardless of the user's JSON key order. Sub-pattern order inside a
// single permission key is preserved — only top-level keys are sorted.
const entries = Object.entries(permission).sort(([a], [b]) => {
const aWild = a.includes("*")
const bWild = b.includes("*")
return aWild === bWild ? 0 : aWild ? -1 : 1
})
const ruleset: Ruleset = []
for (const [key, value] of Object.entries(permission)) {
for (const [key, value] of entries) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
continue
+4 -20
View File
@@ -36,6 +36,7 @@ export const LspTool = Tool.define(
Effect.gen(function* () {
const lsp = yield* LSP.Service
const fs = yield* AppFileSystem.Service
return {
description: DESCRIPTION,
parameters: Parameters,
@@ -46,29 +47,12 @@ export const LspTool = Tool.define(
Effect.gen(function* () {
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
yield* assertExternalDirectoryEffect(ctx, file)
const meta =
args.operation === "workspaceSymbol"
? { operation: args.operation }
: args.operation === "documentSymbol"
? { operation: args.operation, filePath: file }
: { operation: args.operation, filePath: file, line: args.line, character: args.character }
yield* ctx.ask({
permission: "lsp",
patterns: ["*"],
always: ["*"],
metadata: meta,
})
yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })
const uri = pathToFileURL(file).href
const position = { file, line: args.line - 1, character: args.character - 1 }
const relPath = path.relative(Instance.worktree, file)
const detail =
args.operation === "workspaceSymbol"
? ""
: args.operation === "documentSymbol"
? relPath
: `${relPath}:${args.line}:${args.character}`
const title = detail ? `${args.operation} ${detail}` : args.operation
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
const exists = yield* fs.existsSafe(file)
if (!exists) throw new Error(`File not found: ${file}`)
@@ -106,7 +90,7 @@ export const LspTool = Tool.define(
metadata: { result },
output: result.length === 0 ? `No results found for ${args.operation}` : JSON.stringify(result, null, 2),
}
}).pipe(Effect.orDie),
}),
}
}),
)
+17 -7
View File
@@ -1495,9 +1495,16 @@ test("merges legacy tools with existing permission config", async () => {
})
})
test("permission config preserves user key order", async () => {
// Permission precedence follows the order users write in config, so parsing
// must not canonicalise known keys ahead of wildcard or custom keys.
test("permission config canonicalises known keys first, preserves rest-key insertion order", async () => {
// ConfigPermission.Info is a StructWithRest schema — the decoder reorders
// keys into declaration-order for known permission names (edit, read,
// todowrite, external_directory are declared in `config/permission.ts`),
// followed by rest keys in the user's insertion order.
//
// Rule precedence is NOT affected by this reordering: `Permission.fromConfig`
// sorts wildcards before specifics before iterating. See the
// "fromConfig - specific key beats wildcard regardless of JSON key order"
// test in test/permission/next.test.ts for the behavioural guarantee.
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
@@ -1525,12 +1532,15 @@ test("permission config preserves user key order", async () => {
fn: async () => {
const config = await load()
expect(Object.keys(config.permission!)).toEqual([
"*",
"edit",
"write",
"external_directory",
// known fields that the user provided, in declaration order from
// config/permission.ts (read, edit, ..., external_directory, todowrite)
"read",
"edit",
"external_directory",
"todowrite",
// rest keys (not in the known list), in user's insertion order
"*",
"write",
"thoughts_*",
"reasoning_model_*",
"tools_*",
+30 -14
View File
@@ -128,45 +128,61 @@ test("fromConfig - does not expand tilde in middle of path", () => {
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
})
// Permission precedence follows config insertion order. `evaluate()` uses the
// last matching rule, so later config entries intentionally override earlier
// entries even when a wildcard appears after a specific permission.
// Top-level wildcard-vs-specific precedence semantics.
//
// fromConfig sorts top-level keys so wildcard permissions (containing "*")
// come before specific permissions. Combined with `findLast` in evaluate(),
// this gives the intuitive semantic "specific tool rules override the `*`
// fallback", regardless of the order the user wrote the keys in their JSON.
//
// Sub-pattern order inside a single permission key (e.g. `bash: { "*": "allow", "rm": "deny" }`)
// still depends on insertion order — only top-level keys are sorted.
test("fromConfig - preserves top-level config key order", () => {
test("fromConfig - specific key beats wildcard regardless of JSON key order", () => {
const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" })
const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" })
expect(wildcardFirst.map((r) => r.permission)).toEqual(["*", "bash"])
expect(specificFirst.map((r) => r.permission)).toEqual(["bash", "*"])
// Both orderings produce the same ruleset
expect(wildcardFirst).toEqual(specificFirst)
// And both evaluate bash → allow (bash rule wins over * fallback)
expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow")
expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("deny")
expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("allow")
})
test("fromConfig - wildcard acts as fallback when it appears before specifics", () => {
const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow" })
test("fromConfig - wildcard acts as fallback for permissions with no specific rule", () => {
const ruleset = Permission.fromConfig({ bash: "allow", "*": "ask" })
expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("ask")
expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
})
test("fromConfig - top-level ordering is not sorted by wildcard specificity", () => {
test("fromConfig - top-level ordering: wildcards first, specifics after", () => {
const ruleset = Permission.fromConfig({
bash: "allow",
"*": "ask",
edit: "deny",
"mcp_*": "allow",
})
expect(ruleset.map((r) => r.permission)).toEqual(["bash", "*", "edit", "mcp_*"])
// wildcards (* and mcp_*) come before specifics (bash, edit)
const permissions = ruleset.map((r) => r.permission)
expect(permissions.slice(0, 2).sort()).toEqual(["*", "mcp_*"])
expect(permissions.slice(2)).toEqual(["bash", "edit"])
})
test("fromConfig - sub-pattern insertion order inside a tool key is preserved", () => {
test("fromConfig - sub-pattern insertion order inside a tool key is preserved (only top-level sorts)", () => {
// Sub-patterns within a single tool key use the documented "`*` first,
// specific patterns after" convention (findLast picks specifics). The
// top-level sort must not touch sub-pattern ordering.
const ruleset = Permission.fromConfig({ bash: { "*": "deny", "git *": "allow" } })
expect(ruleset.map((r) => r.pattern)).toEqual(["*", "git *"])
// * fallback for unknown commands
expect(Permission.evaluate("bash", "rm foo", ruleset).action).toBe("deny")
// specific pattern wins for git commands (it's last, findLast picks it)
expect(Permission.evaluate("bash", "git status", ruleset).action).toBe("allow")
})
test("fromConfig - documented fallback-first example", () => {
test("fromConfig - canonical documented example unchanged", () => {
// Regression guard for the example in docs/permissions.mdx
const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow", edit: "deny" })
expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("deny")
@@ -432,7 +448,7 @@ test("evaluate - wildcard permission fallback for unknown tool", () => {
expect(result.action).toBe("ask")
})
test("evaluate - later wildcard permission can override earlier specific permission", () => {
test("evaluate - permission patterns sorted by length regardless of object order", () => {
const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "*", pattern: "*", action: "deny" },
-162
View File
@@ -1,162 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import path from "path"
import { Agent } from "../../src/agent/agent"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { LSP } from "../../src/lsp"
import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { MessageID, SessionID } from "../../src/session/schema"
import { Tool, Truncate } from "../../src/tool"
import { LspTool } from "../../src/tool/lsp"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await Instance.disposeAll()
})
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const lsp = Layer.succeed(
LSP.Service,
LSP.Service.of({
init: () => Effect.void,
status: () => Effect.succeed([]),
hasClients: () => Effect.succeed(true),
touchFile: () => Effect.void,
diagnostics: () => Effect.succeed({}),
hover: () => Effect.succeed([]),
definition: () => Effect.succeed([]),
references: () => Effect.succeed([]),
implementation: () => Effect.succeed([]),
documentSymbol: () => Effect.succeed([]),
workspaceSymbol: () => Effect.succeed([]),
prepareCallHierarchy: () => Effect.succeed([]),
incomingCalls: () => Effect.succeed([]),
outgoingCalls: () => Effect.succeed([]),
}),
)
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
AppFileSystem.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Truncate.defaultLayer,
lsp,
),
)
const init = Effect.fn("LspToolTest.init")(function* () {
const info = yield* LspTool
return yield* info.init()
})
const run = Effect.fn("LspToolTest.run")(function* (
args: Tool.InferParameters<typeof LspTool>,
next: Tool.Context = ctx,
) {
const tool = yield* init()
return yield* tool.execute(args, next)
})
const put = Effect.fn("LspToolTest.put")(function* (file: string) {
const fs = yield* AppFileSystem.Service
yield* fs.writeWithDirs(file, "export const x = 1\n")
})
const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
items.push(req)
}),
},
}
}
describe("tool.lsp", () => {
describe("permission metadata", () => {
it.live("keeps cursor details for position-based operations", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const file = path.join(dir, "test.ts")
yield* put(file)
const { items, next } = asks()
const result = yield* run({ operation: "goToDefinition", filePath: file, line: 3, character: 7 }, next)
const req = items.find((item) => item.permission === "lsp")
expect(req).toBeDefined()
expect(req!.metadata).toEqual({
operation: "goToDefinition",
filePath: file,
line: 3,
character: 7,
})
expect(result.title).toBe("goToDefinition test.ts:3:7")
}),
{ git: true },
),
)
it.live("omits cursor details for documentSymbol", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const file = path.join(dir, "test.ts")
yield* put(file)
const { items, next } = asks()
const result = yield* run({ operation: "documentSymbol", filePath: file, line: 3, character: 7 }, next)
const req = items.find((item) => item.permission === "lsp")
expect(req).toBeDefined()
expect(req!.metadata).toEqual({
operation: "documentSymbol",
filePath: file,
})
expect(result.title).toBe("documentSymbol test.ts")
}),
{ git: true },
),
)
it.live("omits file and cursor details for workspaceSymbol", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const file = path.join(dir, "test.ts")
yield* put(file)
const { items, next } = asks()
const result = yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7 }, next)
const req = items.find((item) => item.permission === "lsp")
expect(req).toBeDefined()
expect(req!.metadata).toEqual({
operation: "workspaceSymbol",
})
expect(result.title).toBe("workspaceSymbol")
}),
{ git: true },
),
)
})
})
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.14.25",
"version": "1.14.24",
"type": "module",
"license": "MIT",
"scripts": {
@@ -34,8 +34,8 @@
}
},
"devDependencies": {
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.1.103",
"@opentui/solid": "0.1.103",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.14.25",
"version": "1.14.24",
"type": "module",
"license": "MIT",
"scripts": {
+2 -4
View File
@@ -1206,9 +1206,7 @@ export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConf
export type PermissionConfig =
| PermissionActionConfig
| ({
[key: string]: PermissionRuleConfig
} & {
| {
read?: PermissionRuleConfig
edit?: PermissionRuleConfig
glob?: PermissionRuleConfig
@@ -1226,7 +1224,7 @@ export type PermissionConfig =
doom_loop?: PermissionActionConfig
skill?: PermissionRuleConfig
[key: string]: PermissionRuleConfig | PermissionActionConfig | undefined
})
}
export type AgentConfig = {
model?: string
+52 -65
View File
@@ -10951,73 +10951,60 @@
"$ref": "#/components/schemas/PermissionActionConfig"
},
{
"allOf": [
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/components/schemas/PermissionRuleConfig"
}
"type": "object",
"properties": {
"read": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
{
"type": "object",
"properties": {
"read": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"edit": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"glob": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"grep": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"list": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"bash": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"task": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"external_directory": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"todowrite": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"question": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"webfetch": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"websearch": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"codesearch": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"lsp": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"doom_loop": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"skill": {
"$ref": "#/components/schemas/PermissionRuleConfig"
}
},
"additionalProperties": {
"$ref": "#/components/schemas/PermissionRuleConfig"
}
"edit": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"glob": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"grep": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"list": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"bash": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"task": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"external_directory": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"todowrite": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"question": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"webfetch": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"websearch": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"codesearch": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"lsp": {
"$ref": "#/components/schemas/PermissionRuleConfig"
},
"doom_loop": {
"$ref": "#/components/schemas/PermissionActionConfig"
},
"skill": {
"$ref": "#/components/schemas/PermissionRuleConfig"
}
]
},
"additionalProperties": {
"$ref": "#/components/schemas/PermissionRuleConfig"
}
}
]
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.25",
"version": "1.14.24",
"name": "@opencode-ai/shared",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.14.25",
"version": "1.14.24",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.14.25",
"version": "1.14.24",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.14.25",
"version": "1.14.24",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.14.25",
"version": "1.14.24",
"publisher": "sst-dev",
"repository": {
"type": "git",