feat(core): add location-based permission service (#30287)

This commit is contained in:
Dax
2026-06-01 21:32:50 -04:00
committed by GitHub
parent acd620f411
commit 9b815bcbd2
65 changed files with 4970 additions and 552 deletions

View File

@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Config } from "@/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Provider } from "@/provider/provider"
@@ -36,7 +37,7 @@ export const Info = Schema.Struct({
topP: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Finite),
color: Schema.optional(Schema.String),
permission: Permission.Ruleset,
permission: PermissionLegacy.Ruleset,
model: Schema.optional(
Schema.Struct({
modelID: ProviderV2.ModelID,

View File

@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import type { Permission } from "../permission"
import type { Agent } from "./agent"
@@ -15,10 +16,10 @@ import type { Agent } from "./agent"
* doesn't already permit them.
*/
export function deriveSubagentSessionPermission(input: {
parentSessionPermission: Permission.Ruleset
parentSessionPermission: PermissionLegacy.Ruleset
parentAgent: Agent.Info | undefined
subagent: Agent.Info
}): Permission.Ruleset {
}): PermissionLegacy.Ruleset {
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
const parentAgentDenies =

View File

@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { EOL } from "os"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { basename } from "path"
@@ -193,12 +194,12 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
ask(req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) {
return Effect.sync(() => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset })
throw new PermissionLegacy.DeniedError({ ruleset })
}
}
})

View File

@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
// CLI entry point for `opencode run`.
//
// Handles three modes:
@@ -367,7 +368,7 @@ export const RunCommand = effectCmd({
process.exit(1)
}
const rules: Permission.Ruleset = args.interactive
const rules: PermissionLegacy.Ruleset = args.interactive
? []
: [
{

View File

@@ -1 +1 @@
export { evaluate } from "@opencode-ai/core/permission"
export { evaluate } from "."

View File

@@ -1,142 +1,53 @@
import { ConfigPermission } from "@/config/permission"
import { InstanceState } from "@/effect/instance-state"
import { ProjectV2 } from "@opencode-ai/core/project"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@opencode-ai/core/session/sql"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Wildcard } from "@opencode-ai/core/util/wildcard"
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { Deferred, Effect, Layer, Context } from "effect"
import os from "os"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionID } from "./schema"
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "permission" })
export const Action = PermissionV2.Action.annotate({ identifier: "PermissionAction" })
export type Action = Schema.Schema.Type<typeof Action>
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "PermissionRule" })
export type Rule = Schema.Schema.Type<typeof Rule>
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
// Pure data; nothing checks class identity. As `Schema.Struct` + type alias,
// `Permission.ask` can trust its already-typed input and skip the inner
// `decodeUnknownSync` that would otherwise throw uncaught on any structural
// mismatch. Same pattern as `Question.Request` in PR #28570.
export const Request = Schema.Struct({
id: PermissionID,
sessionID: SessionID,
permission: Schema.String,
patterns: Schema.Array(Schema.String),
metadata: Schema.Record(Schema.String, Schema.Unknown),
always: Schema.Array(Schema.String),
tool: Schema.optional(
Schema.Struct({
messageID: MessageID,
callID: Schema.String,
}),
),
}).annotate({ identifier: "PermissionRequest" })
export type Request = Schema.Schema.Type<typeof Request>
export const Reply = Schema.Literals(["once", "always", "reject"])
export type Reply = Schema.Schema.Type<typeof Reply>
const reply = {
reply: Reply,
message: Schema.optional(Schema.String),
}
export const ReplyBody = Schema.Struct(reply).annotate({ identifier: "PermissionReplyBody" })
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
export const Approval = Schema.Struct({
projectID: ProjectV2.ID,
patterns: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionApproval" })
export type Approval = Schema.Schema.Type<typeof Approval>
export const Event = {
Asked: EventV2.define({ type: "permission.asked", schema: Request.fields }),
Asked: EventV2.define({ type: "permission.asked", schema: PermissionLegacy.Request.fields }),
Replied: EventV2.define({
type: "permission.replied",
schema: {
sessionID: SessionID,
requestID: PermissionID,
reply: Reply,
sessionID: PermissionLegacy.Request.fields.sessionID,
requestID: PermissionLegacy.ID,
reply: PermissionLegacy.Reply,
},
}),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() {
return "The user rejected permission to use this specific tool call."
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Permission.NotFoundError", {
requestID: PermissionID,
}) {}
export type Error = DeniedError | RejectedError | CorrectedError
export const AskInput = Schema.Struct({
...Request.fields,
id: Schema.optional(PermissionID),
ruleset: Ruleset,
}).annotate({ identifier: "PermissionAskInput" })
export type AskInput = Schema.Schema.Type<typeof AskInput>
export const ReplyInput = Schema.Struct({
requestID: PermissionID,
...reply,
}).annotate({ identifier: "PermissionReplyInput" })
export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
export interface Interface {
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
readonly ask: (input: PermissionLegacy.AskInput) => Effect.Effect<void, PermissionLegacy.Error>
readonly reply: (input: PermissionLegacy.ReplyInput) => Effect.Effect<void, PermissionLegacy.NotFoundError>
readonly list: () => Effect.Effect<ReadonlyArray<PermissionLegacy.Request>>
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
info: PermissionLegacy.Request
deferred: Deferred.Deferred<void, PermissionLegacy.RejectedError | PermissionLegacy.CorrectedError>
}
interface State {
pending: Map<PermissionID, PendingEntry>
approved: Rule[]
pending: Map<PermissionLegacy.ID, PendingEntry>
approved: PermissionLegacy.Rule[]
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return PermissionV2.evaluate(permission, pattern, ...rulesets)
export function evaluate(permission: string, pattern: string, ...rulesets: PermissionLegacy.Ruleset[]): PermissionLegacy.Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
}
)
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
@@ -145,24 +56,18 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const { db } = yield* Database.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) {
const row = yield* db
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, ctx.project.id))
.get()
.pipe(Effect.orDie)
void ctx
const state = {
pending: new Map<PermissionID, PendingEntry>(),
approved: [...(row?.data ?? [])],
pending: new Map<PermissionLegacy.ID, PendingEntry>(),
approved: [],
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.fail(item.deferred, new RejectedError())
yield* Deferred.fail(item.deferred, new PermissionLegacy.RejectedError())
}
state.pending.clear()
}),
@@ -172,7 +77,7 @@ export const layer = Layer.effect(
}),
)
const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
const ask = Effect.fn("Permission.ask")(function* (input: PermissionLegacy.AskInput) {
const { approved, pending } = yield* InstanceState.get(state)
const { ruleset, ...request } = input
let needsAsk = false
@@ -181,7 +86,7 @@ export const layer = Layer.effect(
const rule = evaluate(request.permission, pattern, ruleset, approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") {
return yield* new DeniedError({
return yield* new PermissionLegacy.DeniedError({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
})
}
@@ -191,8 +96,8 @@ export const layer = Layer.effect(
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
const id = request.id ?? PermissionLegacy.ID.ascending()
const info: PermissionLegacy.Request = {
id,
sessionID: request.sessionID,
permission: request.permission,
@@ -203,7 +108,7 @@ export const layer = Layer.effect(
}
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
const deferred = yield* Deferred.make<void, PermissionLegacy.RejectedError | PermissionLegacy.CorrectedError>()
pending.set(id, { info, deferred })
yield* events.publish(Event.Asked, info)
return yield* Effect.ensuring(
@@ -214,10 +119,10 @@ export const layer = Layer.effect(
)
})
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
const reply = Effect.fn("Permission.reply")(function* (input: PermissionLegacy.ReplyInput) {
const { approved, pending } = yield* InstanceState.get(state)
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
if (!existing) return yield* new PermissionLegacy.NotFoundError({ requestID: input.requestID })
pending.delete(input.requestID)
yield* events.publish(Event.Replied, {
@@ -229,7 +134,7 @@ export const layer = Layer.effect(
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
input.message ? new PermissionLegacy.CorrectedError({ feedback: input.message }) : new PermissionLegacy.RejectedError(),
)
for (const [id, item] of pending.entries()) {
@@ -240,7 +145,7 @@ export const layer = Layer.effect(
requestID: item.info.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
yield* Deferred.fail(item.deferred, new PermissionLegacy.RejectedError())
}
return
}
@@ -290,7 +195,7 @@ function expand(pattern: string): string {
}
export function fromConfig(permission: ConfigPermission.Info) {
const ruleset: Rule[] = []
const ruleset: PermissionLegacy.Rule[] = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
@@ -303,14 +208,21 @@ export function fromConfig(permission: ConfigPermission.Info) {
return ruleset
}
export function merge(...rulesets: Ruleset[]): Rule[] {
return [...PermissionV2.merge(...rulesets)]
export function merge(...rulesets: PermissionLegacy.Ruleset[]): PermissionLegacy.Rule[] {
return rulesets.flat()
}
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return PermissionV2.disabled(tools, ruleset)
export function disabled(tools: string[], ruleset: PermissionLegacy.Ruleset): Set<string> {
const edits = ["edit", "write", "apply_patch"]
return new Set(
tools.filter((tool) => {
const permission = edits.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
)
}
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
export * as Permission from "."

View File

@@ -1,13 +0,0 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { Newtype } from "@opencode-ai/core/schema"
export class PermissionID extends Newtype<PermissionID>()(
"PermissionID",
Schema.String.check(Schema.isStartsWith("per")),
) {
static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id))
}
}

View File

@@ -1,7 +1,7 @@
import { and, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -86,10 +86,6 @@ export function fromRow(row: Row): Info {
}
}
function mergePermissionRules<T extends readonly unknown[]>(oldRules: T, newRules: T): T {
return [...new Map([...oldRules, ...newRules].map((rule) => [JSON.stringify(rule), rule])).values()] as unknown as T
}
export const UpdateInput = Schema.Struct({
projectID: ProjectV2.ID,
name: Schema.optional(Schema.String),
@@ -201,36 +197,6 @@ export const layer = Layer.effect(
.run()
}
const oldPermission = yield* d
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, oldID))
.get()
const newPermission = yield* d
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, newID))
.get()
if (oldPermission && newPermission) {
yield* d
.update(PermissionTable)
.set({
data: mergePermissionRules(oldPermission.data, newPermission.data),
time_created: Math.min(oldPermission.time_created, newPermission.time_created),
time_updated: Date.now(),
})
.where(eq(PermissionTable.project_id, newID))
.run()
yield* d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
}
if (oldPermission && !newPermission) {
yield* d
.update(PermissionTable)
.set({ project_id: newID })
.where(eq(PermissionTable.project_id, oldID))
.run()
}
yield* d
.update(SessionTable)
.set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })

View File

@@ -1,5 +1,5 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError } from "../errors"
@@ -10,7 +10,7 @@ import { described } from "./metadata"
const root = "/permission"
const ReplyPayload = Schema.Struct({
reply: Permission.Reply,
reply: PermissionLegacy.Reply,
message: Schema.optional(Schema.String),
})
@@ -20,7 +20,7 @@ export const PermissionApi = HttpApi.make("permission")
.add(
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
success: described(Schema.Array(PermissionLegacy.Request), "List of pending permissions"),
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.list",
@@ -29,7 +29,7 @@ export const PermissionApi = HttpApi.make("permission")
}),
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: PermissionID },
params: { requestID: PermissionLegacy.ID },
query: WorkspaceRoutingQuery,
payload: ReplyPayload,
success: described(Schema.Boolean, "Permission processed successfully"),

View File

@@ -1,6 +1,6 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Permission } from "@/permission"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { PermissionID } from "@/permission/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
@@ -48,7 +48,7 @@ export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
export const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String),
metadata: Schema.optional(Session.Metadata),
permission: Schema.optional(Permission.Ruleset),
permission: Schema.optional(PermissionLegacy.Ruleset),
time: Schema.optional(
Schema.Struct({
archived: Schema.optional(Session.ArchivedTimestamp),
@@ -71,7 +71,7 @@ export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInp
export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))
export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"]))
export const PermissionResponsePayload = Schema.Struct({
response: Permission.Reply,
response: PermissionLegacy.Reply,
})
export const SessionPaths = {
@@ -392,7 +392,7 @@ export const SessionApi = HttpApi.make("session")
}),
),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID },
params: { sessionID: SessionID, permissionID: PermissionLegacy.ID },
query: WorkspaceRoutingQuery,
payload: PermissionResponsePayload,
success: described(Schema.Boolean, "Permission processed successfully"),

View File

@@ -3,12 +3,16 @@ import { MessageGroup } from "./v2/message"
import { ModelGroup } from "./v2/model"
import { ProviderGroup } from "./v2/provider"
import { SessionGroup } from "./v2/session"
import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission"
export const V2Api = HttpApi.make("v2")
.add(SessionGroup)
.add(MessageGroup)
.add(ModelGroup)
.add(ProviderGroup)
.add(PermissionGroup)
.add(SessionPermissionGroup)
.add(PermissionSavedGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",

View File

@@ -1,6 +1,7 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect, Layer, Schema } from "effect"
@@ -34,7 +35,7 @@ export const locationQueryOpenApi = OpenApi.annotations({
export class V2LocationMiddleware extends HttpApiMiddleware.Service<
V2LocationMiddleware,
{
provides: Catalog.Service | PluginBoot.Service
provides: Catalog.Service | PluginBoot.Service | PermissionV2.Service
}
>()("@opencode/ExperimentalHttpApiV2Location") {}
@@ -59,4 +60,4 @@ export const layer = Layer.effect(
}),
)
}),
).pipe(Layer.provide(LocationServiceMap.layer))
)

View File

@@ -0,0 +1,90 @@
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
import { V2Authorization } from "../../middleware/authorization"
import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
export const PermissionGroup = HttpApiGroup.make("v2.permission")
.add(
HttpApiEndpoint.get("permissionRequests", "/api/permission/request", {
query: LocationQuery,
success: Schema.Array(PermissionV2.Request),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.request.list",
summary: "List pending permission requests",
description: "Retrieve pending permission requests for a location.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "v2 permissions", description: "Experimental v2 permission routes." }))
.middleware(V2LocationMiddleware)
.middleware(V2Authorization)
export const SessionPermissionGroup = HttpApiGroup.make("v2.session.permission")
.add(
HttpApiEndpoint.get("sessionPermissionRequests", "/api/session/:sessionID/permission/request", {
params: { sessionID: SessionV2.ID },
success: Schema.Array(PermissionV2.Request),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("permissionRequestReply", "/api/session/:sessionID/permission/request/:requestID/reply", {
params: { sessionID: SessionV2.ID, requestID: PermissionV2.ID },
payload: Schema.Struct({
reply: PermissionV2.Reply,
message: Schema.String.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, PermissionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.reply",
summary: "Reply to pending permission request",
description: "Respond to a pending permission request owned by a session.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "v2 session permissions", description: "Experimental v2 session permission routes." }))
.middleware(V2Authorization)
export const PermissionSavedGroup = HttpApiGroup.make("v2.permission.saved")
.add(
HttpApiEndpoint.get("savedPermissions", "/api/permission/saved", {
query: Schema.Struct({ projectID: ProjectV2.ID.pipe(Schema.optional) }),
success: Schema.Array(PermissionSaved.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.list",
summary: "List saved permissions",
description: "Retrieve saved permissions, optionally filtered by project.",
}),
),
)
.add(
HttpApiEndpoint.delete("removeSavedPermission", "/api/permission/saved/:id", {
params: { id: PermissionSaved.ID },
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.remove",
summary: "Remove saved permission",
description: "Remove a saved permission by ID.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "v2 saved permissions", description: "Experimental v2 saved permission routes." }))
.middleware(V2Authorization)

View File

@@ -1,5 +1,5 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
@@ -14,8 +14,8 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss
})
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
params: { requestID: PermissionID }
payload: Permission.ReplyBody
params: { requestID: PermissionLegacy.ID }
payload: PermissionLegacy.ReplyBody
}) {
yield* svc
.reply({

View File

@@ -1,9 +1,9 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Agent } from "@/agent/agent"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Command } from "@/command"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
@@ -360,7 +360,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
params: { sessionID: SessionID; permissionID: PermissionID }
params: { sessionID: SessionID; permissionID: PermissionLegacy.ID }
payload: typeof PermissionResponsePayload.Type
}) {
yield* requireSession(ctx.params.sessionID)

View File

@@ -1,12 +1,25 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Layer } from "effect"
import { layer as v2LocationLayer } from "../groups/v2/location"
import { messageHandlers } from "./v2/message"
import { modelHandlers } from "./v2/model"
import { providerHandlers } from "./v2/provider"
import { sessionHandlers } from "./v2/session"
import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./v2/permission"
export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe(
export const v2Handlers = Layer.mergeAll(
sessionHandlers,
messageHandlers,
modelHandlers,
providerHandlers,
permissionHandlers,
sessionPermissionHandlers,
savedPermissionHandlers,
).pipe(
Layer.provide(v2LocationLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(PermissionSaved.layer),
Layer.provide(SessionV2.defaultLayer),
)

View File

@@ -0,0 +1,105 @@
import { Database } from "@opencode-ai/core/database/database"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
import { PermissionNotFoundError, SessionNotFoundError } from "../../errors"
function missingRequest(id: PermissionV2.ID) {
return new PermissionNotFoundError({ requestID: id, message: `Permission request not found: ${id}` })
}
export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission", (handlers) =>
Effect.gen(function* () {
return handlers.handle(
"permissionRequests",
Effect.fn(function* () {
return yield* (yield* PermissionV2.Service).list()
}),
)
}),
)
export const sessionPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session.permission", (handlers) =>
Effect.gen(function* () {
const { db } = yield* Database.Service
const locations = yield* LocationServiceMap
const withSessionPermission = Effect.fnUntraced(function* <A, E>(
sessionID: Parameters<PermissionV2.Interface["forSession"]>[0],
use: (permission: PermissionV2.Interface) => Effect.Effect<A, E>,
) {
const row = yield* db
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
if (!row)
return yield* new SessionNotFoundError({
sessionID,
message: `Session not found: ${sessionID}`,
})
return yield* Effect.gen(function* () {
return yield* use(yield* PermissionV2.Service)
}).pipe(
Effect.scoped,
Effect.provide(
locations.get({ directory: AbsolutePath.make(row.directory), workspaceID: row.workspaceID ?? undefined }),
),
)
})
return handlers
.handle(
"sessionPermissionRequests",
Effect.fn(function* (ctx) {
return yield* withSessionPermission(ctx.params.sessionID, (permission) =>
permission.forSession(ctx.params.sessionID),
)
}),
)
.handle(
"permissionRequestReply",
Effect.fn(function* (ctx) {
yield* withSessionPermission(ctx.params.sessionID, (permission) =>
Effect.gen(function* () {
const request = yield* permission.get(ctx.params.requestID)
if (!request || request.sessionID !== ctx.params.sessionID)
return yield* missingRequest(ctx.params.requestID)
yield* permission
.reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message })
.pipe(Effect.catchTag("PermissionV2.NotFoundError", () => missingRequest(ctx.params.requestID)))
}),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
)
export const savedPermissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.permission.saved", (handlers) =>
Effect.gen(function* () {
const saved = yield* PermissionSaved.Service
return handlers
.handle(
"savedPermissions",
Effect.fn(function* (ctx) {
return yield* saved.list({ projectID: ctx.query.projectID })
}),
)
.handle(
"removeSavedPermission",
Effect.fn(function* (ctx) {
yield* saved.remove(ctx.params.id)
return HttpApiSchema.NoContent.make()
}),
)
}),
)

View File

@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Provider } from "@/provider/provider"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -15,7 +16,6 @@ import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Wildcard } from "@/util/wildcard"
@@ -38,7 +38,7 @@ export type StreamInput = {
parentSessionID?: string
model: Provider.Model
agent: Agent.Info
permission?: Permission.Ruleset
permission?: PermissionLegacy.Ruleset
system: string[]
messages: ModelMessage[]
small?: boolean
@@ -165,7 +165,7 @@ const live: Layer.Layer<
return { approved: true }
}
const id = PermissionID.ascending()
const id = PermissionLegacy.ID.ascending()
let unsub: EventV2.Unsubscribe | undefined
try {
unsub = await bridge.promise(

View File

@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import type { Auth } from "@/auth"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import type { RuntimeFlags } from "@/effect/runtime-flags"
@@ -22,7 +23,7 @@ type PrepareInput = {
readonly parentSessionID?: string
readonly model: Provider.Model
readonly agent: Agent.Info
readonly permission?: Permission.Ruleset
readonly permission?: PermissionLegacy.Ruleset
readonly system: string[]
readonly messages: ModelMessage[]
readonly small?: boolean

View File

@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Image } from "@/image/image"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
@@ -204,7 +205,7 @@ export const layer = Layer.effect(
time: { start: match.part.state.time.start, end: Date.now() },
},
})
if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {
if (error instanceof PermissionLegacy.RejectedError || error instanceof Question.RejectedError) {
ctx.blocked = ctx.shouldBreak
}
yield* settleToolCall(toolCallID)

View File

@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import path from "path"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import os from "os"
@@ -1220,7 +1221,7 @@ export const layer = Layer.effect(
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
const permissions: Permission.Rule[] = []
const permissions: PermissionLegacy.Rule[] = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
}

View File

@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Slug } from "@opencode-ai/core/util/slug"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -233,7 +234,7 @@ export const Info = Schema.Struct({
version: Schema.String,
metadata: optionalOmitUndefined(Metadata),
time: Time,
permission: optionalOmitUndefined(Permission.Ruleset),
permission: optionalOmitUndefined(PermissionLegacy.Ruleset),
revert: optionalOmitUndefined(Revert),
}).annotate({ identifier: "Session" })
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
@@ -258,7 +259,7 @@ export const CreateInput = Schema.optional(
agent: Schema.optional(Schema.String),
model: Schema.optional(Model),
metadata: Schema.optional(Metadata),
permission: Schema.optional(Permission.Ruleset),
permission: Schema.optional(PermissionLegacy.Ruleset),
workspaceID: Schema.optional(WorkspaceV2.ID),
}),
)
@@ -282,7 +283,7 @@ export const SetMetadataInput = Schema.Struct({
})
export const SetPermissionInput = Schema.Struct({
sessionID: SessionID,
permission: Permission.Ruleset,
permission: PermissionLegacy.Ruleset,
})
export const SetRevertInput = Schema.Struct({
sessionID: SessionID,
@@ -348,7 +349,7 @@ const UpdatedInfo = Schema.Struct({
version: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(Schema.NullOr(Metadata)),
time: Schema.optional(UpdatedTime),
permission: Schema.optional(Schema.NullOr(Permission.Ruleset)),
permission: Schema.optional(Schema.NullOr(PermissionLegacy.Ruleset)),
revert: Schema.optional(Schema.NullOr(Revert)),
})
@@ -472,7 +473,7 @@ export interface Interface {
agent?: string
model?: Schema.Schema.Type<typeof Model>
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset
permission?: PermissionLegacy.Ruleset
workspaceID?: WorkspaceV2.ID
}) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
@@ -481,7 +482,7 @@ export interface Interface {
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: PermissionLegacy.Ruleset }) => Effect.Effect<void>
readonly setRevert: (input: {
sessionID: SessionID
revert: Info["revert"]
@@ -570,7 +571,7 @@ export const layer: Layer.Layer<
directory: string
path?: string
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset
permission?: PermissionLegacy.Ruleset
}) {
const ctx = yield* InstanceState.context
const result: Info = {
@@ -748,7 +749,7 @@ export const layer: Layer.Layer<
agent?: string
model?: Schema.Schema.Type<typeof Model>
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset
permission?: PermissionLegacy.Ruleset
workspaceID?: WorkspaceV2.ID
}) {
const ctx = yield* InstanceState.context
@@ -842,7 +843,7 @@ export const layer: Layer.Layer<
const setPermission = Effect.fn("Session.setPermission")(function* (input: {
sessionID: SessionID
permission: Permission.Ruleset
permission: PermissionLegacy.Ruleset
}) {
yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } }).pipe(
Effect.orDie,

View File

@@ -3,7 +3,7 @@ import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
import { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
import { SessionShareTable } from "@opencode-ai/core/share/sql"
import path from "path"
import { existsSync } from "fs"
@@ -108,13 +108,12 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
// Pre-scan all files upfront to avoid repeated glob operations
log.info("scanning files...")
const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, permFiles, shareFiles] = await Promise.all([
const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, shareFiles] = await Promise.all([
list("project/*.json"),
list("session/*/*.json"),
list("message/*/*.json"),
list("part/*/*.json"),
list("todo/*.json"),
list("permission/*.json"),
list("session_share/*.json"),
])
@@ -124,7 +123,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
messages: messageFiles.length,
parts: partFiles.length,
todos: todoFiles.length,
permissions: permFiles.length,
shares: shareFiles.length,
})
@@ -135,7 +133,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
messageFiles.length +
partFiles.length +
todoFiles.length +
permFiles.length +
shareFiles.length,
)
const progress = options?.progress
@@ -357,31 +354,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
log.warn("skipped orphaned todos", { count: orphans.todos })
}
// Migrate permissions
const permProjects = permFiles.map((file) => path.basename(file, ".json"))
const permValues: unknown[] = []
for (let i = 0; i < permFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, permFiles.length)
const batch = await read(permFiles, i, end)
permValues.length = 0
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
const projectID = permProjects[i + j]
if (!projectIds.has(projectID)) {
orphans.permissions++
continue
}
permValues.push({ project_id: projectID, data })
}
stats.permissions += insert(permValues, PermissionTable, "permission")
step("permissions", end - i)
}
log.info("migrated permissions", { count: stats.permissions })
if (orphans.permissions > 0) {
log.warn("skipped orphaned permissions", { count: orphans.permissions })
}
// Migrate session shares
const shareSessions = shareFiles.map((file) => path.basename(file, ".json"))
const shareValues: unknown[] = []

View File

@@ -1,5 +1,5 @@
export { AccountTable, AccountStateTable, ControlAccountTable } from "@opencode-ai/core/account/sql"
export { ProjectTable } from "@opencode-ai/core/project/sql"
export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
export { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
export { SessionShareTable } from "@opencode-ai/core/share/sql"
export { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"

View File

@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Effect, Schema } from "effect"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import type { JSONSchema7 } from "@ai-sdk/provider"
@@ -41,7 +42,7 @@ export type Context<M extends Metadata = Metadata> = {
extra?: { [key: string]: unknown }
messages: SessionLegacy.WithParts[]
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
ask(input: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
}
export interface ExecuteResult<M extends Metadata = Metadata> {