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 ee0c4aa7b6
commit 3056f6c030
65 changed files with 4970 additions and 552 deletions
@@ -0,0 +1 @@
DROP TABLE `permission`;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
CREATE TABLE `permission` (
`id` text PRIMARY KEY,
`project_id` text NOT NULL,
`action` text NOT NULL,
`resource` text NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
CONSTRAINT `fk_permission_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE UNIQUE INDEX `permission_project_action_resource_idx` ON `permission` (`project_id`,`action`,`resource`);
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -3,7 +3,7 @@ export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Scope } from "effect" import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer" import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
import { PermissionV2 } from "./permission" import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider" import { ProviderV2 } from "./provider"
import { PositiveInt } from "./schema" import { PositiveInt } from "./schema"
import { State } from "./state" import { State } from "./state"
@@ -26,7 +26,7 @@ export class Info extends Schema.Class<Info>("AgentV2.Info")({
hidden: Schema.Boolean, hidden: Schema.Boolean,
color: Color.pipe(Schema.optional), color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional), steps: PositiveInt.pipe(Schema.optional),
permissions: PermissionV2.Ruleset, permissions: PermissionSchema.Ruleset,
}) { }) {
static empty(id: ID) { static empty(id: ID) {
return new Info({ return new Info({
+2
View File
@@ -24,5 +24,7 @@ export const migrations = (
import("./migration/20260511000411_data_migration_state"), import("./migration/20260511000411_data_migration_state"),
import("./migration/20260511173437_session-metadata"), import("./migration/20260511173437_session-metadata"),
import("./migration/20260601010001_normalize_storage_paths"), import("./migration/20260601010001_normalize_storage_paths"),
import("./migration/20260601202201_amazing_prowler"),
import("./migration/20260602002951_lowly_union_jack"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260601202201_amazing_prowler",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP TABLE \`permission\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,22 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260602002951_lowly_union_jack",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`permission\` (
\`id\` text PRIMARY KEY,
\`project_id\` text NOT NULL,
\`action\` text NOT NULL,
\`resource\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`fk_permission_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`)
})
},
} satisfies DatabaseMigration.Migration
+8
View File
@@ -13,6 +13,10 @@ import { Npm } from "./npm"
import { ModelsDev } from "./models-dev" import { ModelsDev } from "./models-dev"
import { AppFileSystem } from "./filesystem" import { AppFileSystem } from "./filesystem"
import { Global } from "./global" import { Global } from "./global"
import { Database } from "./database/database"
import { PermissionV2 } from "./permission"
import { PermissionSaved } from "./permission/saved"
import { SessionV2 } from "./session"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", { export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => { lookup: (ref: Location.Ref) => {
@@ -25,6 +29,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Catalog.locationLayer, Catalog.locationLayer,
AgentV2.locationLayer, AgentV2.locationLayer,
PluginBoot.locationLayer, PluginBoot.locationLayer,
PermissionV2.locationLayer,
).pipe(Layer.provideMerge(location), Layer.fresh) ).pipe(Layer.provideMerge(location), Layer.fresh)
}, },
idleTimeToLive: "60 minutes", idleTimeToLive: "60 minutes",
@@ -36,5 +41,8 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
ModelsDev.defaultLayer, ModelsDev.defaultLayer,
AppFileSystem.defaultLayer, AppFileSystem.defaultLayer,
Global.defaultLayer, Global.defaultLayer,
Database.defaultLayer,
SessionV2.defaultLayer,
PermissionSaved.defaultLayer,
], ],
}) {} }) {}
+279 -34
View File
@@ -1,42 +1,110 @@
export * as PermissionV2 from "./permission" export * as PermissionV2 from "./permission"
import { Schema } from "effect" import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
import { EventV2 } from "./event"
import { Location } from "./location"
import { AgentV2 } from "./agent"
import { SessionV2 } from "./session"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { Wildcard } from "./util/wildcard" import { Wildcard } from "./util/wildcard"
import { Identifier } from "./id/id" import { PermissionSchema } from "./permission/schema"
import { Newtype } from "./schema" import { PermissionSaved } from "./permission/saved"
export class PermissionID extends Newtype<PermissionID>()( export { Effect, Rule, Ruleset } from "./permission/schema"
"PermissionID", type Effect = PermissionSchema.Effect
Schema.String.check(Schema.isStartsWith("per")), type Rule = PermissionSchema.Rule
) { type Ruleset = PermissionSchema.Ruleset
static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id)) export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
} Schema.brand("PermissionV2.ID"),
withStatics((schema) => ({ create: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const Source = Schema.Union([
Schema.Struct({
type: Schema.Literal("tool"),
messageID: Schema.String,
callID: Schema.String,
}),
]).annotate({ identifier: "PermissionV2.Source" })
export type Source = typeof Source.Type
export const Request = Schema.Struct({
id: ID,
sessionID: SessionV2.ID,
action: Schema.String,
resources: Schema.Array(Schema.String),
save: Schema.Array(Schema.String).pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}).annotate({ identifier: "PermissionV2.Request" })
export type Request = typeof Request.Type
export const Reply = Schema.Literals(["once", "always", "reject"]).annotate({ identifier: "PermissionV2.Reply" })
export type Reply = typeof Reply.Type
export const AssertInput = Schema.Struct({
id: ID.pipe(Schema.optional),
sessionID: SessionV2.ID,
action: Schema.String,
resources: Schema.Array(Schema.String),
save: Schema.Array(Schema.String).pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}).annotate({ identifier: "PermissionV2.AssertInput" })
export type AssertInput = typeof AssertInput.Type
export const ReplyInput = Schema.Struct({
requestID: ID,
reply: Reply,
message: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "PermissionV2.ReplyInput" })
export type ReplyInput = typeof ReplyInput.Type
export const AskResult = Schema.Struct({
id: ID,
effect: PermissionSchema.Effect,
}).annotate({ identifier: "PermissionV2.AskResult" })
export type AskResult = typeof AskResult.Type
export const Event = {
Asked: EventV2.define({ type: "permission.v2.asked", schema: Request.fields }),
Replied: EventV2.define({
type: "permission.v2.replied",
schema: {
sessionID: SessionV2.ID,
requestID: ID,
reply: Reply,
},
}),
} }
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "Permission.Action" }) export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
export type Action = typeof Action.Type
export const Rule = Schema.Struct({ export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
permission: Schema.String, feedback: Schema.String,
pattern: Schema.String, }) {}
action: Action,
}).annotate({ identifier: "Permission.Rule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "Permission.Ruleset" }) export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
export type Ruleset = typeof Ruleset.Type rules: PermissionSchema.Ruleset,
}) {}
const EDIT_TOOLS = ["edit", "write", "apply_patch"] export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
requestID: ID,
}) {}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { export type Error = DeniedError | RejectedError | CorrectedError
export function evaluate(action: string, resource: string, ...rulesets: Ruleset[]): Rule {
return ( return (
rulesets rulesets
.flat() .flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? { .findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? {
action: "ask", action,
permission, resource: "*",
pattern: "*", effect: "ask",
} }
) )
} }
@@ -45,12 +113,189 @@ export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat() return rulesets.flat()
} }
export function disabled(tools: string[], ruleset: Ruleset): Set<string> { export interface Interface {
return new Set( readonly ask: (input: AssertInput) => EffectRuntime.Effect<AskResult, SessionV2.NotFoundError>
tools.filter((tool) => { readonly assert: (input: AssertInput) => EffectRuntime.Effect<void, Error | SessionV2.NotFoundError>
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool readonly reply: (input: ReplyInput) => EffectRuntime.Effect<void, NotFoundError>
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) readonly get: (id: ID) => EffectRuntime.Effect<Request | undefined>
return rule?.pattern === "*" && rule.action === "deny" readonly forSession: (sessionID: SessionV2.ID) => EffectRuntime.Effect<ReadonlyArray<Request>>
}), readonly list: () => EffectRuntime.Effect<ReadonlyArray<Request>>
)
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Permission") {}
interface Pending {
readonly request: Request
readonly deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export const layer = Layer.effect(
Service,
EffectRuntime.gen(function* () {
const events = yield* EventV2.Service
const location = yield* Location.Service
const agents = yield* AgentV2.Service
const sessions = yield* SessionV2.Service
const saved = yield* PermissionSaved.Service
const pending = new Map<ID, Pending>()
yield* EffectRuntime.addFinalizer(() =>
EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
discard: true,
}).pipe(
EffectRuntime.ensuring(
EffectRuntime.sync(() => {
pending.clear()
}),
),
),
)
const savedRules = EffectRuntime.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
(item): Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
)
})
const configured = EffectRuntime.fn("PermissionV2.configured")(function* (sessionID: SessionV2.ID) {
const session = yield* sessions.get(sessionID)
if (!session.agent) return []
return (yield* agents.get(AgentV2.ID.make(session.agent)))?.permissions ?? []
})
function denied(input: AssertInput, rules: Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
function relevant(input: AssertInput, rules: Ruleset) {
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
}
const evaluateInput = EffectRuntime.fnUntraced(function* (input: AssertInput) {
const rules = yield* configured(input.sessionID)
if (denied(input, rules)) return { effect: "deny" as const, rules }
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return { effect, rules: all }
})
function request(input: AssertInput): Request {
return {
id: input.id ?? ID.create(),
sessionID: input.sessionID,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
}
}
const create = EffectRuntime.fnUntraced(function* (request: Request) {
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
const item = { request, deferred }
pending.set(request.id, item)
yield* events.publish(Event.Asked, request)
return item
})
const ask = EffectRuntime.fn("PermissionV2.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
const value = request(input)
if (result.effect === "ask") yield* create(value)
return { id: value.id, effect: result.effect }
})
const assert = EffectRuntime.fn("PermissionV2.assert")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new DeniedError({
rules: relevant(input, result.rules),
})
}
if (result.effect === "allow") return
const item = yield* create(request(input))
return yield* Deferred.await(item.deferred).pipe(
EffectRuntime.ensuring(
EffectRuntime.sync(() => {
pending.delete(item.request.id)
}),
),
)
})
const reply = EffectRuntime.fn("PermissionV2.reply")(function* (input: ReplyInput) {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
pending.delete(input.requestID)
yield* events.publish(Event.Replied, {
sessionID: existing.request.sessionID,
requestID: existing.request.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
)
for (const [id, item] of pending) {
if (item.request.sessionID !== existing.request.sessionID) continue
pending.delete(id)
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
}
return
}
if (input.reply === "always" && existing.request.save?.length) {
yield* saved.add({ projectID: location.project.id, action: existing.request.action, resources: existing.request.save })
}
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply !== "always" || !existing.request.save?.length) return
const rememberedRules = yield* savedRules()
for (const [id, item] of pending) {
const input = { ...item.request }
const rules = yield* configured(item.request.sessionID).pipe(
EffectRuntime.catchTag("Session.NotFoundError", () => EffectRuntime.succeed(undefined)),
)
if (!rules) continue
if (denied(input, rules)) continue
const effective = [...rules, ...rememberedRules]
if (
!item.request.resources.every((resource) => evaluate(item.request.action, resource, effective).effect === "allow")
)
continue
pending.delete(id)
yield* events.publish(Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
}
})
const list = EffectRuntime.fn("PermissionV2.list")(function* () {
return Array.from(pending.values(), (item) => item.request)
})
const get = EffectRuntime.fn("PermissionV2.get")(function* (id: ID) {
return pending.get(id)?.request
})
const forSession = EffectRuntime.fn("PermissionV2.forSession")(function* (sessionID: SessionV2.ID) {
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ ask, assert, reply, get, forSession, list })
}),
)
export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))
+96
View File
@@ -0,0 +1,96 @@
export * as PermissionLegacy from "./legacy"
import { Schema } from "effect"
import { ProjectV2 } from "../project"
import { withStatics } from "../schema"
import { SessionSchema } from "../session/schema"
import { Identifier } from "../util/identifier"
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
Schema.brand("PermissionID"),
withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" })
export type Action = typeof Action.Type
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "PermissionRule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
export type Ruleset = typeof Ruleset.Type
export const Request = Schema.Struct({
id: ID,
sessionID: SessionSchema.ID,
permission: Schema.String,
patterns: Schema.Array(Schema.String),
metadata: Schema.Record(Schema.String, Schema.Unknown),
always: Schema.Array(Schema.String),
tool: Schema.Struct({
messageID: Schema.String,
callID: Schema.String,
}).pipe(Schema.optional),
}).annotate({ identifier: "PermissionRequest" })
export type Request = typeof Request.Type
export const Reply = Schema.Literals(["once", "always", "reject"])
export type Reply = typeof Reply.Type
export const ReplyBody = Schema.Struct({
reply: Reply,
message: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "PermissionReplyBody" })
export type ReplyBody = typeof ReplyBody.Type
export const Approval = Schema.Struct({
projectID: ProjectV2.ID,
patterns: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionApproval" })
export type Approval = typeof Approval.Type
export const AskInput = Schema.Struct({
...Request.fields,
id: ID.pipe(Schema.optional),
ruleset: Ruleset,
}).annotate({ identifier: "PermissionAskInput" })
export type AskInput = typeof AskInput.Type
export const ReplyInput = Schema.Struct({
requestID: ID,
...ReplyBody.fields,
}).annotate({ identifier: "PermissionReplyInput" })
export type ReplyInput = typeof ReplyInput.Type
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: ID,
}) {}
export type Error = DeniedError | RejectedError | CorrectedError
+78
View File
@@ -0,0 +1,78 @@
export * as PermissionSaved from "./saved"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { ProjectV2 } from "../project"
import { withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { PermissionTable } from "./sql"
export const ID = Schema.String.pipe(
Schema.brand("PermissionSaved.ID"),
withStatics((schema) => ({ create: () => schema.make("psv_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const Info = Schema.Struct({
id: ID,
projectID: ProjectV2.ID,
action: Schema.String,
resource: Schema.String,
}).annotate({ identifier: "PermissionSaved.Info" })
export type Info = typeof Info.Type
export const ListInput = Schema.Struct({
projectID: ProjectV2.ID.pipe(Schema.optional),
}).annotate({ identifier: "PermissionSaved.ListInput" })
export type ListInput = typeof ListInput.Type
export const AddInput = Schema.Struct({
projectID: ProjectV2.ID,
action: Schema.String,
resources: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionSaved.AddInput" })
export type AddInput = typeof AddInput.Type
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
readonly add: (input: AddInput) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PermissionSaved") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const list = Effect.fn("PermissionSaved.list")(function* (input?: ListInput) {
const rows = yield* db
.select()
.from(PermissionTable)
.where(input?.projectID ? eq(PermissionTable.project_id, input.projectID) : undefined)
.all()
.pipe(Effect.orDie)
return rows.map((row): Info => ({ id: row.id, projectID: row.project_id, action: row.action, resource: row.resource }))
})
const add = Effect.fn("PermissionSaved.add")(function* (input: AddInput) {
if (!input.resources.length) return
yield* db
.insert(PermissionTable)
.values(input.resources.map((resource) => ({ id: ID.create(), project_id: input.projectID, action: input.action, resource })))
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
})
const remove = Effect.fn("PermissionSaved.remove")(function* (id: ID) {
yield* db.delete(PermissionTable).where(eq(PermissionTable.id, id)).run().pipe(Effect.orDie)
})
return Service.of({ list, add, remove })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
+16
View File
@@ -0,0 +1,16 @@
export * as PermissionSchema from "./schema"
import { Schema } from "effect"
export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" })
export type Effect = typeof Effect.Type
export const Rule = Schema.Struct({
action: Schema.String,
resource: Schema.String,
effect: Effect,
}).annotate({ identifier: "PermissionV2.Rule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
export type Ruleset = typeof Ruleset.Type
+20
View File
@@ -0,0 +1,20 @@
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import { ProjectV2 } from "../project"
import { ProjectTable } from "../project/sql"
import type { PermissionSaved } from "./saved"
export const PermissionTable = sqliteTable(
"permission",
{
id: text().$type<PermissionSaved.ID>().primaryKey(),
project_id: text()
.$type<ProjectV2.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
action: text().notNull(),
resource: text().notNull(),
...Timestamps,
},
(table) => [uniqueIndex("permission_project_action_resource_idx").on(table.project_id, table.action, table.resource)],
)
+34 -34
View File
@@ -104,23 +104,23 @@ export const Plugin = PluginV2.define({
const worktree = location.directory const worktree = location.directory
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")] const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: PermissionV2.Ruleset = [ const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ permission: "external_directory", pattern: "*", action: "ask" }, { action: "external_directory", resource: "*", effect: "ask" },
...whitelistedDirs.map( ...whitelistedDirs.map(
(pattern): PermissionV2.Rule => ({ permission: "external_directory", pattern, action: "allow" }), (resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }),
), ),
] ]
const defaults: PermissionV2.Ruleset = [ const defaults: PermissionV2.Ruleset = [
{ permission: "*", pattern: "*", action: "allow" }, { action: "*", resource: "*", effect: "allow" },
...readonlyExternalDirectory, ...readonlyExternalDirectory,
{ permission: "question", pattern: "*", action: "deny" }, { action: "question", resource: "*", effect: "deny" },
{ permission: "plan_enter", pattern: "*", action: "deny" }, { action: "plan_enter", resource: "*", effect: "deny" },
{ permission: "plan_exit", pattern: "*", action: "deny" }, { action: "plan_exit", resource: "*", effect: "deny" },
{ permission: "repo_clone", pattern: "*", action: "deny" }, { action: "repo_clone", resource: "*", effect: "deny" },
{ permission: "repo_overview", pattern: "*", action: "deny" }, { action: "repo_overview", resource: "*", effect: "deny" },
{ permission: "read", pattern: "*", action: "allow" }, { action: "read", resource: "*", effect: "allow" },
{ permission: "read", pattern: "*.env", action: "ask" }, { action: "read", resource: "*.env", effect: "ask" },
{ permission: "read", pattern: "*.env.*", action: "ask" }, { action: "read", resource: "*.env.*", effect: "ask" },
{ permission: "read", pattern: "*.env.example", action: "allow" }, { action: "read", resource: "*.env.example", effect: "allow" },
] ]
yield* agent.update((editor) => { yield* agent.update((editor) => {
@@ -129,8 +129,8 @@ export const Plugin = PluginV2.define({
item.mode = "primary" item.mode = "primary"
item.permissions.push( item.permissions.push(
...PermissionV2.merge(defaults, [ ...PermissionV2.merge(defaults, [
{ permission: "question", pattern: "*", action: "allow" }, { action: "question", resource: "*", effect: "allow" },
{ permission: "plan_enter", pattern: "*", action: "allow" }, { action: "plan_enter", resource: "*", effect: "allow" },
]), ]),
) )
}) })
@@ -140,15 +140,15 @@ export const Plugin = PluginV2.define({
item.mode = "primary" item.mode = "primary"
item.permissions.push( item.permissions.push(
...PermissionV2.merge(defaults, [ ...PermissionV2.merge(defaults, [
{ permission: "question", pattern: "*", action: "allow" }, { action: "question", resource: "*", effect: "allow" },
{ permission: "plan_exit", pattern: "*", action: "allow" }, { action: "plan_exit", resource: "*", effect: "allow" },
{ permission: "external_directory", pattern: path.join(Global.Path.data, "plans", "*"), action: "allow" }, { action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
{ permission: "edit", pattern: "*", action: "deny" }, { action: "edit", resource: "*", effect: "deny" },
{ permission: "edit", pattern: path.join(".opencode", "plans", "*.md"), action: "allow" }, { action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" },
{ {
permission: "edit", action: "edit",
pattern: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")), resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
action: "allow", effect: "allow",
}, },
]), ]),
) )
@@ -159,7 +159,7 @@ export const Plugin = PluginV2.define({
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel." "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent" item.mode = "subagent"
item.permissions.push( item.permissions.push(
...PermissionV2.merge(defaults, [{ permission: "todowrite", pattern: "*", action: "deny" }]), ...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }]),
) )
}) })
@@ -172,14 +172,14 @@ export const Plugin = PluginV2.define({
...PermissionV2.merge( ...PermissionV2.merge(
defaults, defaults,
[ [
{ permission: "*", pattern: "*", action: "deny" }, { action: "*", resource: "*", effect: "deny" },
{ permission: "grep", pattern: "*", action: "allow" }, { action: "grep", resource: "*", effect: "allow" },
{ permission: "glob", pattern: "*", action: "allow" }, { action: "glob", resource: "*", effect: "allow" },
{ permission: "list", pattern: "*", action: "allow" }, { action: "list", resource: "*", effect: "allow" },
{ permission: "bash", pattern: "*", action: "allow" }, { action: "bash", resource: "*", effect: "allow" },
{ permission: "webfetch", pattern: "*", action: "allow" }, { action: "webfetch", resource: "*", effect: "allow" },
{ permission: "websearch", pattern: "*", action: "allow" }, { action: "websearch", resource: "*", effect: "allow" },
{ permission: "read", pattern: "*", action: "allow" }, { action: "read", resource: "*", effect: "allow" },
], ],
readonlyExternalDirectory, readonlyExternalDirectory,
), ),
@@ -190,21 +190,21 @@ export const Plugin = PluginV2.define({
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_COMPACTION item.system = PROMPT_COMPACTION
item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }])) item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
}) })
editor.update(AgentV2.ID.make("title"), (item) => { editor.update(AgentV2.ID.make("title"), (item) => {
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_TITLE item.system = PROMPT_TITLE
item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }])) item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
}) })
editor.update(AgentV2.ID.make("summary"), (item) => { editor.update(AgentV2.ID.make("summary"), (item) => {
item.mode = "primary" item.mode = "primary"
item.hidden = true item.hidden = true
item.system = PROMPT_SUMMARY item.system = PROMPT_SUMMARY
item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }])) item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
}) })
}) })
}), }),
+2 -2
View File
@@ -2,7 +2,7 @@ export * as SessionLegacy from "./legacy"
import { Effect, Schema, Types } from "effect" import { Effect, Schema, Types } from "effect"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { PermissionV2 } from "../permission" import { PermissionLegacy } from "../permission/legacy"
import { ProjectV2 } from "../project" import { ProjectV2 } from "../project"
import { ProviderV2 } from "../provider" import { ProviderV2 } from "../provider"
import { optionalOmitUndefined, withStatics } from "../schema" import { optionalOmitUndefined, withStatics } from "../schema"
@@ -558,7 +558,7 @@ export const SessionInfo = Schema.Struct({
compacting: optionalOmitUndefined(NonNegativeInt), compacting: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(Schema.Finite), archived: optionalOmitUndefined(Schema.Finite),
}), }),
permission: optionalOmitUndefined(PermissionV2.Ruleset), permission: optionalOmitUndefined(PermissionLegacy.Ruleset),
revert: optionalOmitUndefined(SessionRevert), revert: optionalOmitUndefined(SessionRevert),
}).annotate({ identifier: "Session" }) }).annotate({ identifier: "Session" })
export type SessionInfo = typeof SessionInfo.Type export type SessionInfo = typeof SessionInfo.Type
+2 -10
View File
@@ -3,7 +3,7 @@ import * as DatabasePath from "../database/path"
import { ProjectTable } from "../project/sql" import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message" import type { SessionMessage } from "./message"
import type { Snapshot } from "../snapshot" import type { Snapshot } from "../snapshot"
import { PermissionV2 } from "../permission" import { PermissionLegacy } from "../permission/legacy"
import { ProjectV2 } from "../project" import { ProjectV2 } from "../project"
import type { SessionSchema } from "./schema" import type { SessionSchema } from "./schema"
import type { MessageID, PartID, Info as LegacyMessageInfo, Part as LegacyMessagePart } from "./legacy" import type { MessageID, PartID, Info as LegacyMessageInfo, Part as LegacyMessagePart } from "./legacy"
@@ -42,7 +42,7 @@ export const SessionTable = sqliteTable(
tokens_cache_read: integer().notNull().default(0), tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0), tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(),
permission: text({ mode: "json" }).$type<PermissionV2.Ruleset>(), permission: text({ mode: "json" }).$type<PermissionLegacy.Ruleset>(),
agent: text(), agent: text(),
model: text({ mode: "json" }).$type<{ model: text({ mode: "json" }).$type<{
id: string id: string
@@ -129,11 +129,3 @@ export const SessionMessageTable = sqliteTable(
index("session_message_time_created_idx").on(table.time_created), index("session_message_time_created_idx").on(table.time_created),
], ],
) )
export const PermissionTable = sqliteTable("permission", {
project_id: text()
.primaryKey()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<PermissionV2.Ruleset>(),
})
+11 -11
View File
@@ -19,7 +19,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
yield* defaults((editor) => yield* defaults((editor) =>
editor.update(build, (agent) => { editor.update(build, (agent) => {
agent.mode = "primary" agent.mode = "primary"
agent.permissions.push({ permission: "bash", pattern: "*", action: "allow" }) agent.permissions.push({ action: "bash", resource: "*", effect: "allow" })
}), }),
) )
@@ -30,16 +30,16 @@ describe("ConfigAgentPlugin.Plugin", () => {
new Config.Loaded({ new Config.Loaded({
source: { type: "memory" }, source: { type: "memory" },
info: decode({ info: decode({
permissions: [{ permission: "bash", pattern: "*", action: "ask" }], permissions: [{ action: "bash", resource: "*", effect: "ask" }],
agents: { agents: {
build: { build: {
permissions: [{ permission: "bash", pattern: "git *", action: "allow" }], permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
}, },
reviewer: { reviewer: {
model: "openrouter/openai/gpt-5", model: "openrouter/openai/gpt-5",
description: "Review changes", description: "Review changes",
mode: "subagent", mode: "subagent",
permissions: [{ permission: "edit", pattern: "*", action: "deny" }], permissions: [{ action: "edit", resource: "*", effect: "deny" }],
}, },
removed: { description: "Removed later" }, removed: { description: "Removed later" },
}, },
@@ -65,12 +65,12 @@ describe("ConfigAgentPlugin.Plugin", () => {
const buildAgent = yield* agents.get(build) const buildAgent = yield* agents.get(build)
if (!buildAgent) throw new Error("expected configured build agent") if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([ expect(buildAgent.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "allow" }, { action: "bash", resource: "*", effect: "allow" },
{ permission: "bash", pattern: "*", action: "ask" }, { action: "bash", resource: "*", effect: "ask" },
{ permission: "bash", pattern: "git *", action: "allow" }, { action: "bash", resource: "git *", effect: "allow" },
]) ])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow") expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask") expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer")) const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
if (!reviewer) throw new Error("expected configured reviewer agent") if (!reviewer) throw new Error("expected configured reviewer agent")
@@ -81,8 +81,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" }, model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
}) })
expect(reviewer.permissions).toEqual([ expect(reviewer.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "ask" }, { action: "bash", resource: "*", effect: "ask" },
{ permission: "edit", pattern: "*", action: "deny" }, { action: "edit", resource: "*", effect: "deny" },
]) ])
expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined() expect(yield* agents.get(AgentV2.ID.make("removed"))).toBeUndefined()
}), }),
+6 -6
View File
@@ -170,8 +170,8 @@ describe("Config", () => {
enterprise: { url: "https://share.example.com" }, enterprise: { url: "https://share.example.com" },
username: "test-user", username: "test-user",
permissions: [ permissions: [
{ permission: "bash", pattern: "*", action: "ask" }, { action: "bash", resource: "*", effect: "ask" },
{ permission: "bash", pattern: "git status", action: "allow" }, { action: "bash", resource: "git status", effect: "allow" },
], ],
agents: { agents: {
reviewer: { reviewer: {
@@ -188,7 +188,7 @@ describe("Config", () => {
color: "warning", color: "warning",
steps: 12, steps: 12,
disabled: false, disabled: false,
permissions: [{ permission: "edit", pattern: "*", action: "deny" }], permissions: [{ action: "edit", resource: "*", effect: "deny" }],
}, },
}, },
snapshots: false, snapshots: false,
@@ -254,8 +254,8 @@ describe("Config", () => {
expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" }) expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
expect(documents[0]?.info.username).toBe("test-user") expect(documents[0]?.info.username).toBe("test-user")
expect(documents[0]?.info.permissions).toEqual([ expect(documents[0]?.info.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "ask" }, { action: "bash", resource: "*", effect: "ask" },
{ permission: "bash", pattern: "git status", action: "allow" }, { action: "bash", resource: "git status", effect: "allow" },
]) ])
expect(documents[0]?.info.agents?.reviewer).toEqual({ expect(documents[0]?.info.agents?.reviewer).toEqual({
model: "openrouter/openai/gpt-5", model: "openrouter/openai/gpt-5",
@@ -271,7 +271,7 @@ describe("Config", () => {
color: "warning", color: "warning",
steps: 12, steps: 12,
disabled: false, disabled: false,
permissions: [{ permission: "edit", pattern: "*", action: "deny" }], permissions: [{ action: "edit", resource: "*", effect: "deny" }],
}) })
expect(documents[0]?.info.snapshots).toBe(false) expect(documents[0]?.info.snapshots).toBe(false)
expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] }) expect(documents[0]?.info.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
@@ -43,7 +43,7 @@ describe("DatabaseMigration", () => {
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({ expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
name: "session", name: "session",
}) })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 22 }) expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 24 })
}), }),
) )
}) })
+176
View File
@@ -0,0 +1,176 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionTable } from "@opencode-ai/core/permission/sql"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { eq } from "drizzle-orm"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const database = Database.layerFromPath(":memory:")
const current = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
)
const events = EventV2.layer.pipe(Layer.provide(database))
const sessions = SessionV2.layer.pipe(Layer.provide(database))
const saved = PermissionSaved.layer.pipe(Layer.provide(database))
const layer = PermissionV2.locationLayer.pipe(
Layer.provideMerge(database),
Layer.provideMerge(events),
Layer.provideMerge(current),
Layer.provideMerge(sessions),
Layer.provideMerge(saved),
)
const it = testEffect(layer)
function setup(rules: PermissionV2.Ruleset = []) {
return Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: SessionV2.ID.make("ses_test"),
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
agent: "test",
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* setRules(rules)
})
}
function setRules(rules: PermissionV2.Ruleset) {
return Effect.gen(function* () {
const agents = yield* AgentV2.Service
const update = yield* agents.transform()
yield* update((editor) =>
editor.update(AgentV2.ID.make("test"), (agent) => {
agent.permissions = [...rules]
}),
)
})
}
function assertion(input: Partial<PermissionV2.AssertInput> = {}) {
return {
id: PermissionV2.ID.create("per_test"),
sessionID: SessionV2.ID.make("ses_test"),
action: "read",
resources: ["src/index.ts"],
...input,
} satisfies PermissionV2.AssertInput
}
function waitForRequest() {
return Effect.gen(function* () {
const service = yield* PermissionV2.Service
const events = yield* EventV2.Service
const asked = yield* Deferred.make<PermissionV2.Request>()
const unsubscribe = yield* events.listen((event) =>
event.type === PermissionV2.Event.Asked.type
? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.assert(assertion()).pipe(Effect.forkScoped)
const request = yield* Deferred.await(asked)
return { service, fiber, request }
})
}
describe("PermissionV2", () => {
it.effect("returns the evaluated effect and only queues prompts", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const service = yield* PermissionV2.Service
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "allow" })
expect(yield* service.list()).toEqual([])
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "deny" })
expect(yield* service.list()).toEqual([])
yield* setRules([])
expect(yield* service.ask(assertion())).toEqual({ id: PermissionV2.ID.create("per_test"), effect: "ask" })
expect(yield* service.get(PermissionV2.ID.create("per_test"))).toBeDefined()
}),
)
it.effect("allows and denies from explicit rules without asking", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const service = yield* PermissionV2.Service
yield* service.assert(assertion())
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
const denied = yield* service.assert(assertion()).pipe(Effect.flip)
expect(denied).toBeInstanceOf(PermissionV2.DeniedError)
expect(yield* service.list()).toEqual([])
}),
)
it.effect("resolves an asked permission once", () =>
Effect.gen(function* () {
yield* setup()
const { service, fiber, request } = yield* waitForRequest()
expect(yield* service.list()).toEqual([request])
expect(yield* service.forSession(request.sessionID)).toEqual([request])
expect(yield* service.forSession(SessionV2.ID.make("ses_other"))).toEqual([])
expect(yield* service.get(request.id)).toEqual(request)
yield* service.reply({ requestID: request.id, reply: "once" })
yield* Fiber.join(fiber)
expect(yield* service.list()).toEqual([])
expect(yield* service.get(request.id)).toBeUndefined()
}),
)
it.effect("stores and removes saved resources for a project", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* PermissionV2.Service
const asked = yield* Deferred.make<PermissionV2.Request>()
const events = yield* EventV2.Service
const unsubscribe = yield* events.listen((event) =>
event.type === PermissionV2.Event.Asked.type
? Deferred.succeed(asked, event.data as PermissionV2.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.assert(assertion({ save: ["src/*"] })).pipe(Effect.forkScoped)
const request = yield* Deferred.await(asked)
yield* service.reply({ requestID: request.id, reply: "always" })
yield* Fiber.join(fiber)
const { db } = yield* Database.Service
expect(yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Project.ID.global)).all()).toMatchObject([
{ action: "read", resource: "src/*" },
])
const saved = yield* PermissionSaved.Service
const id = (yield* saved.list())[0]!.id
expect(yield* saved.list()).toEqual([
{ id, projectID: Project.ID.global, action: "read", resource: "src/*" },
])
yield* service.assert(assertion({ id: PermissionV2.ID.create("per_next"), resources: ["src/next.ts"] }))
yield* saved.remove(id)
expect(yield* saved.list()).toEqual([])
}),
)
})
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
@@ -36,7 +37,7 @@ export const Info = Schema.Struct({
topP: Schema.optional(Schema.Finite), topP: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Finite), temperature: Schema.optional(Schema.Finite),
color: Schema.optional(Schema.String), color: Schema.optional(Schema.String),
permission: Permission.Ruleset, permission: PermissionLegacy.Ruleset,
model: Schema.optional( model: Schema.optional(
Schema.Struct({ Schema.Struct({
modelID: ProviderV2.ModelID, modelID: ProviderV2.ModelID,
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import type { Permission } from "../permission" import type { Permission } from "../permission"
import type { Agent } from "./agent" import type { Agent } from "./agent"
@@ -15,10 +16,10 @@ import type { Agent } from "./agent"
* doesn't already permit them. * doesn't already permit them.
*/ */
export function deriveSubagentSessionPermission(input: { export function deriveSubagentSessionPermission(input: {
parentSessionPermission: Permission.Ruleset parentSessionPermission: PermissionLegacy.Ruleset
parentAgent: Agent.Info | undefined parentAgent: Agent.Info | undefined
subagent: Agent.Info subagent: Agent.Info
}): Permission.Ruleset { }): PermissionLegacy.Ruleset {
const canTask = input.subagent.permission.some((rule) => rule.permission === "task") const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite") const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
const parentAgentDenies = const parentAgentDenies =
+3 -2
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { EOL } from "os" import { EOL } from "os"
import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { basename } from "path" import { basename } from "path"
@@ -193,12 +194,12 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
abort: new AbortController().signal, abort: new AbortController().signal,
messages: [], messages: [],
metadata: () => Effect.void, metadata: () => Effect.void,
ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) { ask(req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) {
return Effect.sync(() => { return Effect.sync(() => {
for (const pattern of req.patterns) { for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, ruleset) const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") { if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset }) throw new PermissionLegacy.DeniedError({ ruleset })
} }
} }
}) })
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
// CLI entry point for `opencode run`. // CLI entry point for `opencode run`.
// //
// Handles three modes: // Handles three modes:
@@ -367,7 +368,7 @@ export const RunCommand = effectCmd({
process.exit(1) process.exit(1)
} }
const rules: Permission.Ruleset = args.interactive const rules: PermissionLegacy.Ruleset = args.interactive
? [] ? []
: [ : [
{ {
+1 -1
View File
@@ -1 +1 @@
export { evaluate } from "@opencode-ai/core/permission" export { evaluate } from "."
+49 -137
View File
@@ -1,142 +1,53 @@
import { ConfigPermission } from "@/config/permission" import { ConfigPermission } from "@/config/permission"
import { InstanceState } from "@/effect/instance-state" 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 * as Log from "@opencode-ai/core/util/log"
import { Wildcard } from "@opencode-ai/core/util/wildcard" 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 os from "os"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { PermissionID } from "./schema"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "permission" }) 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 = { 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({ Replied: EventV2.define({
type: "permission.replied", type: "permission.replied",
schema: { schema: {
sessionID: SessionID, sessionID: PermissionLegacy.Request.fields.sessionID,
requestID: PermissionID, requestID: PermissionLegacy.ID,
reply: Reply, 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 { export interface Interface {
readonly ask: (input: AskInput) => Effect.Effect<void, Error> readonly ask: (input: PermissionLegacy.AskInput) => Effect.Effect<void, PermissionLegacy.Error>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError> readonly reply: (input: PermissionLegacy.ReplyInput) => Effect.Effect<void, PermissionLegacy.NotFoundError>
readonly list: () => Effect.Effect<ReadonlyArray<Request>> readonly list: () => Effect.Effect<ReadonlyArray<PermissionLegacy.Request>>
} }
interface PendingEntry { interface PendingEntry {
info: Request info: PermissionLegacy.Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError> deferred: Deferred.Deferred<void, PermissionLegacy.RejectedError | PermissionLegacy.CorrectedError>
} }
interface State { interface State {
pending: Map<PermissionID, PendingEntry> pending: Map<PermissionLegacy.ID, PendingEntry>
approved: Rule[] approved: PermissionLegacy.Rule[]
} }
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { export function evaluate(permission: string, pattern: string, ...rulesets: PermissionLegacy.Ruleset[]): PermissionLegacy.Rule {
return PermissionV2.evaluate(permission, pattern, ...rulesets) 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") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
@@ -145,24 +56,18 @@ export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2Bridge.Service const events = yield* EventV2Bridge.Service
const { db } = yield* Database.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) { Effect.fn("Permission.state")(function* (ctx) {
const row = yield* db void ctx
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, ctx.project.id))
.get()
.pipe(Effect.orDie)
const state = { const state = {
pending: new Map<PermissionID, PendingEntry>(), pending: new Map<PermissionLegacy.ID, PendingEntry>(),
approved: [...(row?.data ?? [])], approved: [],
} }
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Effect.gen(function* () { Effect.gen(function* () {
for (const item of state.pending.values()) { 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() 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 { approved, pending } = yield* InstanceState.get(state)
const { ruleset, ...request } = input const { ruleset, ...request } = input
let needsAsk = false let needsAsk = false
@@ -181,7 +86,7 @@ export const layer = Layer.effect(
const rule = evaluate(request.permission, pattern, ruleset, approved) const rule = evaluate(request.permission, pattern, ruleset, approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule }) log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") { if (rule.action === "deny") {
return yield* new DeniedError({ return yield* new PermissionLegacy.DeniedError({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
}) })
} }
@@ -191,8 +96,8 @@ export const layer = Layer.effect(
if (!needsAsk) return if (!needsAsk) return
const id = request.id ?? PermissionID.ascending() const id = request.id ?? PermissionLegacy.ID.ascending()
const info: Request = { const info: PermissionLegacy.Request = {
id, id,
sessionID: request.sessionID, sessionID: request.sessionID,
permission: request.permission, permission: request.permission,
@@ -203,7 +108,7 @@ export const layer = Layer.effect(
} }
log.info("asking", { id, permission: info.permission, patterns: info.patterns }) 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 }) pending.set(id, { info, deferred })
yield* events.publish(Event.Asked, info) yield* events.publish(Event.Asked, info)
return yield* Effect.ensuring( 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 { approved, pending } = yield* InstanceState.get(state)
const existing = pending.get(input.requestID) 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) pending.delete(input.requestID)
yield* events.publish(Event.Replied, { yield* events.publish(Event.Replied, {
@@ -229,7 +134,7 @@ export const layer = Layer.effect(
if (input.reply === "reject") { if (input.reply === "reject") {
yield* Deferred.fail( yield* Deferred.fail(
existing.deferred, 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()) { for (const [id, item] of pending.entries()) {
@@ -240,7 +145,7 @@ export const layer = Layer.effect(
requestID: item.info.id, requestID: item.info.id,
reply: "reject", reply: "reject",
}) })
yield* Deferred.fail(item.deferred, new RejectedError()) yield* Deferred.fail(item.deferred, new PermissionLegacy.RejectedError())
} }
return return
} }
@@ -290,7 +195,7 @@ function expand(pattern: string): string {
} }
export function fromConfig(permission: ConfigPermission.Info) { export function fromConfig(permission: ConfigPermission.Info) {
const ruleset: Rule[] = [] const ruleset: PermissionLegacy.Rule[] = []
for (const [key, value] of Object.entries(permission)) { for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") { if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" }) ruleset.push({ permission: key, action: value, pattern: "*" })
@@ -303,14 +208,21 @@ export function fromConfig(permission: ConfigPermission.Info) {
return ruleset return ruleset
} }
export function merge(...rulesets: Ruleset[]): Rule[] { export function merge(...rulesets: PermissionLegacy.Ruleset[]): PermissionLegacy.Rule[] {
return [...PermissionV2.merge(...rulesets)] return rulesets.flat()
} }
export function disabled(tools: string[], ruleset: Ruleset): Set<string> { export function disabled(tools: string[], ruleset: PermissionLegacy.Ruleset): Set<string> {
return PermissionV2.disabled(tools, ruleset) 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 "." export * as Permission from "."
@@ -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))
}
}
+1 -35
View File
@@ -1,7 +1,7 @@
import { and, eq, sql } from "drizzle-orm" import { and, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql" 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 { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag" 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({ export const UpdateInput = Schema.Struct({
projectID: ProjectV2.ID, projectID: ProjectV2.ID,
name: Schema.optional(Schema.String), name: Schema.optional(Schema.String),
@@ -201,36 +197,6 @@ export const layer = Layer.effect(
.run() .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 yield* d
.update(SessionTable) .update(SessionTable)
.set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` }) .set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })
@@ -1,5 +1,5 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Schema } from "effect" import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError } from "../errors" import { PermissionNotFoundError } from "../errors"
@@ -10,7 +10,7 @@ import { described } from "./metadata"
const root = "/permission" const root = "/permission"
const ReplyPayload = Schema.Struct({ const ReplyPayload = Schema.Struct({
reply: Permission.Reply, reply: PermissionLegacy.Reply,
message: Schema.optional(Schema.String), message: Schema.optional(Schema.String),
}) })
@@ -20,7 +20,7 @@ export const PermissionApi = HttpApi.make("permission")
.add( .add(
HttpApiEndpoint.get("list", root, { HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery, query: WorkspaceRoutingQuery,
success: described(Schema.Array(Permission.Request), "List of pending permissions"), success: described(Schema.Array(PermissionLegacy.Request), "List of pending permissions"),
}).annotateMerge( }).annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
identifier: "permission.list", identifier: "permission.list",
@@ -29,7 +29,7 @@ export const PermissionApi = HttpApi.make("permission")
}), }),
), ),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: PermissionID }, params: { requestID: PermissionLegacy.ID },
query: WorkspaceRoutingQuery, query: WorkspaceRoutingQuery,
payload: ReplyPayload, payload: ReplyPayload,
success: described(Schema.Boolean, "Permission processed successfully"), success: described(Schema.Boolean, "Permission processed successfully"),
@@ -1,6 +1,6 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { PermissionID } from "@/permission/schema"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2" import { MessageV2 } from "@/session/message-v2"
@@ -48,7 +48,7 @@ export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
export const UpdatePayload = Schema.Struct({ export const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String), title: Schema.optional(Schema.String),
metadata: Schema.optional(Session.Metadata), metadata: Schema.optional(Session.Metadata),
permission: Schema.optional(Permission.Ruleset), permission: Schema.optional(PermissionLegacy.Ruleset),
time: Schema.optional( time: Schema.optional(
Schema.Struct({ Schema.Struct({
archived: Schema.optional(Session.ArchivedTimestamp), 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 ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))
export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])) export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"]))
export const PermissionResponsePayload = Schema.Struct({ export const PermissionResponsePayload = Schema.Struct({
response: Permission.Reply, response: PermissionLegacy.Reply,
}) })
export const SessionPaths = { export const SessionPaths = {
@@ -392,7 +392,7 @@ export const SessionApi = HttpApi.make("session")
}), }),
), ),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID }, params: { sessionID: SessionID, permissionID: PermissionLegacy.ID },
query: WorkspaceRoutingQuery, query: WorkspaceRoutingQuery,
payload: PermissionResponsePayload, payload: PermissionResponsePayload,
success: described(Schema.Boolean, "Permission processed successfully"), success: described(Schema.Boolean, "Permission processed successfully"),
@@ -3,12 +3,16 @@ import { MessageGroup } from "./v2/message"
import { ModelGroup } from "./v2/model" import { ModelGroup } from "./v2/model"
import { ProviderGroup } from "./v2/provider" import { ProviderGroup } from "./v2/provider"
import { SessionGroup } from "./v2/session" import { SessionGroup } from "./v2/session"
import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission"
export const V2Api = HttpApi.make("v2") export const V2Api = HttpApi.make("v2")
.add(SessionGroup) .add(SessionGroup)
.add(MessageGroup) .add(MessageGroup)
.add(ModelGroup) .add(ModelGroup)
.add(ProviderGroup) .add(ProviderGroup)
.add(PermissionGroup)
.add(SessionPermissionGroup)
.add(PermissionSavedGroup)
.annotateMerge( .annotateMerge(
OpenApi.annotations({ OpenApi.annotations({
title: "opencode experimental HttpApi", title: "opencode experimental HttpApi",
@@ -1,6 +1,7 @@
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { PluginBoot } from "@opencode-ai/core/plugin/boot" import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
@@ -34,7 +35,7 @@ export const locationQueryOpenApi = OpenApi.annotations({
export class V2LocationMiddleware extends HttpApiMiddleware.Service< export class V2LocationMiddleware extends HttpApiMiddleware.Service<
V2LocationMiddleware, V2LocationMiddleware,
{ {
provides: Catalog.Service | PluginBoot.Service provides: Catalog.Service | PluginBoot.Service | PermissionV2.Service
} }
>()("@opencode/ExperimentalHttpApiV2Location") {} >()("@opencode/ExperimentalHttpApiV2Location") {}
@@ -59,4 +60,4 @@ export const layer = Layer.effect(
}), }),
) )
}), }),
).pipe(Layer.provide(LocationServiceMap.layer)) )
@@ -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)
@@ -1,5 +1,5 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api" import { InstanceHttpApi } from "../api"
@@ -14,8 +14,8 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss
}) })
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
params: { requestID: PermissionID } params: { requestID: PermissionLegacy.ID }
payload: Permission.ReplyBody payload: PermissionLegacy.ReplyBody
}) { }) {
yield* svc yield* svc
.reply({ .reply({
@@ -1,9 +1,9 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { Command } from "@/command" import { Command } from "@/command"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { SessionShare } from "@/share/session" import { SessionShare } from "@/share/session"
import { Session } from "@/session/session" import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction" import { SessionCompaction } from "@/session/compaction"
@@ -360,7 +360,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
}) })
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: { const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
params: { sessionID: SessionID; permissionID: PermissionID } params: { sessionID: SessionID; permissionID: PermissionLegacy.ID }
payload: typeof PermissionResponsePayload.Type payload: typeof PermissionResponsePayload.Type
}) { }) {
yield* requireSession(ctx.params.sessionID) yield* requireSession(ctx.params.sessionID)
@@ -1,12 +1,25 @@
import { SessionV2 } from "@opencode-ai/core/session" 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 } from "effect"
import { layer as v2LocationLayer } from "../groups/v2/location" import { layer as v2LocationLayer } from "../groups/v2/location"
import { messageHandlers } from "./v2/message" import { messageHandlers } from "./v2/message"
import { modelHandlers } from "./v2/model" import { modelHandlers } from "./v2/model"
import { providerHandlers } from "./v2/provider" import { providerHandlers } from "./v2/provider"
import { sessionHandlers } from "./v2/session" 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(v2LocationLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(PermissionSaved.layer),
Layer.provide(SessionV2.defaultLayer), Layer.provide(SessionV2.defaultLayer),
) )
@@ -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()
}),
)
}),
)
+3 -3
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { serviceUse } from "@opencode-ai/core/effect/service-use" 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 type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Permission } from "@/permission" import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Wildcard } from "@/util/wildcard" import { Wildcard } from "@/util/wildcard"
@@ -38,7 +38,7 @@ export type StreamInput = {
parentSessionID?: string parentSessionID?: string
model: Provider.Model model: Provider.Model
agent: Agent.Info agent: Agent.Info
permission?: Permission.Ruleset permission?: PermissionLegacy.Ruleset
system: string[] system: string[]
messages: ModelMessage[] messages: ModelMessage[]
small?: boolean small?: boolean
@@ -165,7 +165,7 @@ const live: Layer.Layer<
return { approved: true } return { approved: true }
} }
const id = PermissionID.ascending() const id = PermissionLegacy.ID.ascending()
let unsub: EventV2.Unsubscribe | undefined let unsub: EventV2.Unsubscribe | undefined
try { try {
unsub = await bridge.promise( unsub = await bridge.promise(
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import type { Auth } from "@/auth" import type { Auth } from "@/auth"
import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import type { RuntimeFlags } from "@/effect/runtime-flags" import type { RuntimeFlags } from "@/effect/runtime-flags"
@@ -22,7 +23,7 @@ type PrepareInput = {
readonly parentSessionID?: string readonly parentSessionID?: string
readonly model: Provider.Model readonly model: Provider.Model
readonly agent: Agent.Info readonly agent: Agent.Info
readonly permission?: Permission.Ruleset readonly permission?: PermissionLegacy.Ruleset
readonly system: string[] readonly system: string[]
readonly messages: ModelMessage[] readonly messages: ModelMessage[]
readonly small?: boolean readonly small?: boolean
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Image } from "@/image/image" import { Image } from "@/image/image"
import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect" 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() }, 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 ctx.blocked = ctx.shouldBreak
} }
yield* settleToolCall(toolCallID) yield* settleToolCall(toolCallID)
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import path from "path" import path from "path"
import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import os from "os" import os from "os"
@@ -1220,7 +1221,7 @@ export const layer = Layer.effect(
const message = yield* createUserMessage(input) const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID) yield* sessions.touch(input.sessionID)
const permissions: Permission.Rule[] = [] const permissions: PermissionLegacy.Rule[] = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) { for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" }) permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
} }
+10 -9
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Slug } from "@opencode-ai/core/util/slug" import { Slug } from "@opencode-ai/core/util/slug"
import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { serviceUse } from "@opencode-ai/core/effect/service-use" import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -233,7 +234,7 @@ export const Info = Schema.Struct({
version: Schema.String, version: Schema.String,
metadata: optionalOmitUndefined(Metadata), metadata: optionalOmitUndefined(Metadata),
time: Time, time: Time,
permission: optionalOmitUndefined(Permission.Ruleset), permission: optionalOmitUndefined(PermissionLegacy.Ruleset),
revert: optionalOmitUndefined(Revert), revert: optionalOmitUndefined(Revert),
}).annotate({ identifier: "Session" }) }).annotate({ identifier: "Session" })
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>> export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
@@ -258,7 +259,7 @@ export const CreateInput = Schema.optional(
agent: Schema.optional(Schema.String), agent: Schema.optional(Schema.String),
model: Schema.optional(Model), model: Schema.optional(Model),
metadata: Schema.optional(Metadata), metadata: Schema.optional(Metadata),
permission: Schema.optional(Permission.Ruleset), permission: Schema.optional(PermissionLegacy.Ruleset),
workspaceID: Schema.optional(WorkspaceV2.ID), workspaceID: Schema.optional(WorkspaceV2.ID),
}), }),
) )
@@ -282,7 +283,7 @@ export const SetMetadataInput = Schema.Struct({
}) })
export const SetPermissionInput = Schema.Struct({ export const SetPermissionInput = Schema.Struct({
sessionID: SessionID, sessionID: SessionID,
permission: Permission.Ruleset, permission: PermissionLegacy.Ruleset,
}) })
export const SetRevertInput = Schema.Struct({ export const SetRevertInput = Schema.Struct({
sessionID: SessionID, sessionID: SessionID,
@@ -348,7 +349,7 @@ const UpdatedInfo = Schema.Struct({
version: Schema.optional(Schema.NullOr(Schema.String)), version: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(Schema.NullOr(Metadata)), metadata: Schema.optional(Schema.NullOr(Metadata)),
time: Schema.optional(UpdatedTime), time: Schema.optional(UpdatedTime),
permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), permission: Schema.optional(Schema.NullOr(PermissionLegacy.Ruleset)),
revert: Schema.optional(Schema.NullOr(Revert)), revert: Schema.optional(Schema.NullOr(Revert)),
}) })
@@ -472,7 +473,7 @@ export interface Interface {
agent?: string agent?: string
model?: Schema.Schema.Type<typeof Model> model?: Schema.Schema.Type<typeof Model>
metadata?: typeof Metadata.Type metadata?: typeof Metadata.Type
permission?: Permission.Ruleset permission?: PermissionLegacy.Ruleset
workspaceID?: WorkspaceV2.ID workspaceID?: WorkspaceV2.ID
}) => Effect.Effect<Info> }) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound> 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 setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void> readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setMetadata: (input: typeof SetMetadataInput.Type) => 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: { readonly setRevert: (input: {
sessionID: SessionID sessionID: SessionID
revert: Info["revert"] revert: Info["revert"]
@@ -570,7 +571,7 @@ export const layer: Layer.Layer<
directory: string directory: string
path?: string path?: string
metadata?: typeof Metadata.Type metadata?: typeof Metadata.Type
permission?: Permission.Ruleset permission?: PermissionLegacy.Ruleset
}) { }) {
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const result: Info = { const result: Info = {
@@ -748,7 +749,7 @@ export const layer: Layer.Layer<
agent?: string agent?: string
model?: Schema.Schema.Type<typeof Model> model?: Schema.Schema.Type<typeof Model>
metadata?: typeof Metadata.Type metadata?: typeof Metadata.Type
permission?: Permission.Ruleset permission?: PermissionLegacy.Ruleset
workspaceID?: WorkspaceV2.ID workspaceID?: WorkspaceV2.ID
}) { }) {
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
@@ -842,7 +843,7 @@ export const layer: Layer.Layer<
const setPermission = Effect.fn("Session.setPermission")(function* (input: { const setPermission = Effect.fn("Session.setPermission")(function* (input: {
sessionID: SessionID sessionID: SessionID
permission: Permission.Ruleset permission: PermissionLegacy.Ruleset
}) { }) {
yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } }).pipe( yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } }).pipe(
Effect.orDie, Effect.orDie,
@@ -3,7 +3,7 @@ import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log" import * as Log from "@opencode-ai/core/util/log"
import { ProjectTable } from "@opencode-ai/core/project/sql" 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 { SessionShareTable } from "@opencode-ai/core/share/sql"
import path from "path" import path from "path"
import { existsSync } from "fs" 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 // Pre-scan all files upfront to avoid repeated glob operations
log.info("scanning files...") 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("project/*.json"),
list("session/*/*.json"), list("session/*/*.json"),
list("message/*/*.json"), list("message/*/*.json"),
list("part/*/*.json"), list("part/*/*.json"),
list("todo/*.json"), list("todo/*.json"),
list("permission/*.json"),
list("session_share/*.json"), list("session_share/*.json"),
]) ])
@@ -124,7 +123,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
messages: messageFiles.length, messages: messageFiles.length,
parts: partFiles.length, parts: partFiles.length,
todos: todoFiles.length, todos: todoFiles.length,
permissions: permFiles.length,
shares: shareFiles.length, shares: shareFiles.length,
}) })
@@ -135,7 +133,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
messageFiles.length + messageFiles.length +
partFiles.length + partFiles.length +
todoFiles.length + todoFiles.length +
permFiles.length +
shareFiles.length, shareFiles.length,
) )
const progress = options?.progress 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 }) 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 // Migrate session shares
const shareSessions = shareFiles.map((file) => path.basename(file, ".json")) const shareSessions = shareFiles.map((file) => path.basename(file, ".json"))
const shareValues: unknown[] = [] const shareValues: unknown[] = []
+1 -1
View File
@@ -1,5 +1,5 @@
export { AccountTable, AccountStateTable, ControlAccountTable } from "@opencode-ai/core/account/sql" export { AccountTable, AccountStateTable, ControlAccountTable } from "@opencode-ai/core/account/sql"
export { ProjectTable } from "@opencode-ai/core/project/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 { SessionShareTable } from "@opencode-ai/core/share/sql"
export { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" export { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import type { JSONSchema7 } from "@ai-sdk/provider" import type { JSONSchema7 } from "@ai-sdk/provider"
@@ -41,7 +42,7 @@ export type Context<M extends Metadata = Metadata> = {
extra?: { [key: string]: unknown } extra?: { [key: string]: unknown }
messages: SessionLegacy.WithParts[] messages: SessionLegacy.WithParts[]
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void> 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> { export interface ExecuteResult<M extends Metadata = Metadata> {
+2 -1
View File
@@ -9,6 +9,7 @@ import { Config } from "../../src/config/config"
import { RuntimeFlags } from "../../src/effect/runtime-flags" import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
import { Provider } from "../../src/provider/provider" import { Provider } from "../../src/provider/provider"
import { Skill } from "../../src/skill" import { Skill } from "../../src/skill"
@@ -28,7 +29,7 @@ const it = testEffect(agentLayer())
const scout = testEffect(agentLayer({ experimentalScout: true })) const scout = testEffect(agentLayer({ experimentalScout: true }))
// Helper to evaluate permission for a tool with wildcard pattern // Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionLegacy.Action | undefined {
if (!agent) return undefined if (!agent) return undefined
return Permission.evaluate(permission, "*", agent.permission).action return Permission.evaluate(permission, "*", agent.permission).action
} }
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
/** /**
* Reproducer for opencode issue #26514: * Reproducer for opencode issue #26514:
* *
@@ -60,7 +61,7 @@ it.instance("[#26514] subagent spawned from plan mode inherits read-only restric
// session's `permission` field is empty (Plan Mode lives on the agent // session's `permission` field is empty (Plan Mode lives on the agent
// ruleset, not the session). So we pass [] through as the parent // ruleset, not the session). So we pass [] through as the parent
// session permission, exactly like the actual code path. // session permission, exactly like the actual code path.
const parentSessionPermission: Permission.Ruleset = [] const parentSessionPermission: PermissionLegacy.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({ const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission, parentSessionPermission,
@@ -88,7 +89,7 @@ it.instance("[#26514] explore subagent launched from plan mode also stays read-o
expect(planAgent).toBeDefined() expect(planAgent).toBeDefined()
expect(explore).toBeDefined() expect(explore).toBeDefined()
const parentSessionPermission: Permission.Ruleset = [] const parentSessionPermission: PermissionLegacy.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({ const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission, parentSessionPermission,
parentAgent: planAgent, parentAgent: planAgent,
@@ -113,7 +114,7 @@ it.instance(
expect(planAgent).toBeDefined() expect(planAgent).toBeDefined()
expect(my).toBeDefined() expect(my).toBeDefined()
const parentSessionPermission: Permission.Ruleset = [] const parentSessionPermission: PermissionLegacy.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({ const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission, parentSessionPermission,
parentAgent: planAgent, parentAgent: planAgent,
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Permission } from "../src/permission" import { Permission } from "../src/permission"
@@ -9,7 +10,7 @@ const it = testEffect(Config.defaultLayer)
const load = Config.use.get() const load = Config.use.get()
describe("Permission.evaluate for permission.task", () => { describe("Permission.evaluate for permission.task", () => {
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset => const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionLegacy.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({ Object.entries(rules).map(([pattern, action]) => ({
permission: "task", permission: "task",
pattern, pattern,
@@ -75,7 +76,7 @@ describe("Permission.disabled for task tool", () => {
// Note: The `disabled` function checks if a TOOL should be completely removed from the tool list. // Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
// It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`. // It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
// It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`. // It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset => const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionLegacy.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({ Object.entries(rules).map(([pattern, action]) => ({
permission: "task", permission: "task",
pattern, pattern,
+50 -50
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { test, expect } from "bun:test" import { test, expect } from "bun:test"
import os from "os" import os from "os"
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
@@ -5,7 +6,6 @@ import { EventV2Bridge } from "../../src/event-v2-bridge"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store" import { InstanceStore } from "../../src/project/instance-store"
import { TestInstance, tmpdirScoped } from "../fixture/fixture" import { TestInstance, tmpdirScoped } from "../fixture/fixture"
@@ -261,8 +261,8 @@ test("merge - preserves rule order", () => {
}) })
test("merge - config permission overrides default ask", () => { test("merge - config permission overrides default ask", () => {
const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] const defaults: PermissionLegacy.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const merged = Permission.merge(defaults, config) const merged = Permission.merge(defaults, config)
expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow") expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
@@ -270,8 +270,8 @@ test("merge - config permission overrides default ask", () => {
}) })
test("merge - config ask overrides default allow", () => { test("merge - config ask overrides default allow", () => {
const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const defaults: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const merged = Permission.merge(defaults, config) const merged = Permission.merge(defaults, config)
expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask") expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
@@ -443,8 +443,8 @@ test("evaluate - later wildcard permission can override earlier specific permiss
}) })
test("evaluate - merges multiple rulesets", () => { test("evaluate - merges multiple rulesets", () => {
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] const approved: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
const result = Permission.evaluate("bash", "rm", config, approved) const result = Permission.evaluate("bash", "rm", config, approved)
expect(result.action).toBe("deny") expect(result.action).toBe("deny")
}) })
@@ -588,7 +588,7 @@ it.instance(
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}), }),
) )
expect(err).toBeInstanceOf(Permission.DeniedError) expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
}), }),
{ git: true }, { git: true },
) )
@@ -655,10 +655,10 @@ it.instance(
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2Bridge.Service const events = yield* EventV2Bridge.Service
const seen = yield* Deferred.make<Permission.Request>() const seen = yield* Deferred.make<PermissionLegacy.Request>()
const unsub = yield* events.listen((event) => { const unsub = yield* events.listen((event) => {
if (event.type === Permission.Event.Asked.type) if (event.type === Permission.Event.Asked.type)
Deferred.doneUnsafe(seen, Effect.succeed(event.data as Permission.Request)) Deferred.doneUnsafe(seen, Effect.succeed(event.data as PermissionLegacy.Request))
return Effect.void return Effect.void
}) })
yield* Effect.addFinalizer(() => unsub) yield* Effect.addFinalizer(() => unsub)
@@ -703,7 +703,7 @@ it.instance(
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const fiber = yield* ask({ const fiber = yield* ask({
id: PermissionID.make("per_test1"), id: PermissionLegacy.ID.make("per_test1"),
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -713,7 +713,7 @@ it.instance(
}).pipe(Effect.forkScoped) }).pipe(Effect.forkScoped)
yield* waitForPending(1) yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test1"), reply: "once" }) yield* reply({ requestID: PermissionLegacy.ID.make("per_test1"), reply: "once" })
yield* Fiber.join(fiber) yield* Fiber.join(fiber)
}), }),
{ git: true }, { git: true },
@@ -724,7 +724,7 @@ it.instance(
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const fiber = yield* ask({ const fiber = yield* ask({
id: PermissionID.make("per_test2"), id: PermissionLegacy.ID.make("per_test2"),
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -734,11 +734,11 @@ it.instance(
}).pipe(Effect.forkScoped) }).pipe(Effect.forkScoped)
yield* waitForPending(1) yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test2"), reply: "reject" }) yield* reply({ requestID: PermissionLegacy.ID.make("per_test2"), reply: "reject" })
const exit = yield* Fiber.await(fiber) const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}), }),
{ git: true }, { git: true },
) )
@@ -748,7 +748,7 @@ it.instance(
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const fiber = yield* ask({ const fiber = yield* ask({
id: PermissionID.make("per_test2b"), id: PermissionLegacy.ID.make("per_test2b"),
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -759,7 +759,7 @@ it.instance(
yield* waitForPending(1) yield* waitForPending(1)
yield* reply({ yield* reply({
requestID: PermissionID.make("per_test2b"), requestID: PermissionLegacy.ID.make("per_test2b"),
reply: "reject", reply: "reject",
message: "Use a safer command", message: "Use a safer command",
}) })
@@ -768,7 +768,7 @@ it.instance(
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) { if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause) const err = Cause.squash(exit.cause)
expect(err).toBeInstanceOf(Permission.CorrectedError) expect(err).toBeInstanceOf(PermissionLegacy.CorrectedError)
expect(String(err)).toContain("Use a safer command") expect(String(err)).toContain("Use a safer command")
} }
}), }),
@@ -780,7 +780,7 @@ it.instance(
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const fiber = yield* ask({ const fiber = yield* ask({
id: PermissionID.make("per_test3"), id: PermissionLegacy.ID.make("per_test3"),
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -790,7 +790,7 @@ it.instance(
}).pipe(Effect.forkScoped) }).pipe(Effect.forkScoped)
yield* waitForPending(1) yield* waitForPending(1)
yield* reply({ requestID: PermissionID.make("per_test3"), reply: "always" }) yield* reply({ requestID: PermissionLegacy.ID.make("per_test3"), reply: "always" })
yield* Fiber.join(fiber) yield* Fiber.join(fiber)
const result = yield* ask({ const result = yield* ask({
@@ -811,7 +811,7 @@ it.instance(
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const a = yield* ask({ const a = yield* ask({
id: PermissionID.make("per_test4a"), id: PermissionLegacy.ID.make("per_test4a"),
sessionID: SessionID.make("session_same"), sessionID: SessionID.make("session_same"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -821,7 +821,7 @@ it.instance(
}).pipe(Effect.forkScoped) }).pipe(Effect.forkScoped)
const b = yield* ask({ const b = yield* ask({
id: PermissionID.make("per_test4b"), id: PermissionLegacy.ID.make("per_test4b"),
sessionID: SessionID.make("session_same"), sessionID: SessionID.make("session_same"),
permission: "edit", permission: "edit",
patterns: ["foo.ts"], patterns: ["foo.ts"],
@@ -831,13 +831,13 @@ it.instance(
}).pipe(Effect.forkScoped) }).pipe(Effect.forkScoped)
yield* waitForPending(2) yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test4a"), reply: "reject" }) yield* reply({ requestID: PermissionLegacy.ID.make("per_test4a"), reply: "reject" })
const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)])
expect(Exit.isFailure(ea)).toBe(true) expect(Exit.isFailure(ea)).toBe(true)
expect(Exit.isFailure(eb)).toBe(true) expect(Exit.isFailure(eb)).toBe(true)
if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(Permission.RejectedError) if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(Permission.RejectedError) if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}), }),
{ git: true }, { git: true },
) )
@@ -847,7 +847,7 @@ it.instance(
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const a = yield* ask({ const a = yield* ask({
id: PermissionID.make("per_test5a"), id: PermissionLegacy.ID.make("per_test5a"),
sessionID: SessionID.make("session_same"), sessionID: SessionID.make("session_same"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -857,7 +857,7 @@ it.instance(
}).pipe(Effect.forkScoped) }).pipe(Effect.forkScoped)
const b = yield* ask({ const b = yield* ask({
id: PermissionID.make("per_test5b"), id: PermissionLegacy.ID.make("per_test5b"),
sessionID: SessionID.make("session_same"), sessionID: SessionID.make("session_same"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -867,7 +867,7 @@ it.instance(
}).pipe(Effect.forkScoped) }).pipe(Effect.forkScoped)
yield* waitForPending(2) yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test5a"), reply: "always" }) yield* reply({ requestID: PermissionLegacy.ID.make("per_test5a"), reply: "always" })
yield* Fiber.join(a) yield* Fiber.join(a)
yield* Fiber.join(b) yield* Fiber.join(b)
@@ -881,7 +881,7 @@ it.instance(
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const a = yield* ask({ const a = yield* ask({
id: PermissionID.make("per_test6a"), id: PermissionLegacy.ID.make("per_test6a"),
sessionID: SessionID.make("session_a"), sessionID: SessionID.make("session_a"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -891,7 +891,7 @@ it.instance(
}).pipe(Effect.forkScoped) }).pipe(Effect.forkScoped)
const b = yield* ask({ const b = yield* ask({
id: PermissionID.make("per_test6b"), id: PermissionLegacy.ID.make("per_test6b"),
sessionID: SessionID.make("session_b"), sessionID: SessionID.make("session_b"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -901,10 +901,10 @@ it.instance(
}).pipe(Effect.forkScoped) }).pipe(Effect.forkScoped)
yield* waitForPending(2) yield* waitForPending(2)
yield* reply({ requestID: PermissionID.make("per_test6a"), reply: "always" }) yield* reply({ requestID: PermissionLegacy.ID.make("per_test6a"), reply: "always" })
yield* Fiber.join(a) yield* Fiber.join(a)
expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test6b")]) expect((yield* list()).map((item) => item.id)).toEqual([PermissionLegacy.ID.make("per_test6b")])
yield* rejectAll() yield* rejectAll()
yield* Fiber.await(b) yield* Fiber.await(b)
@@ -917,10 +917,10 @@ it.instance(
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const events = yield* EventV2Bridge.Service const events = yield* EventV2Bridge.Service
const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }>() const seen = yield* Deferred.make<{ sessionID: SessionID; requestID: PermissionLegacy.ID; reply: PermissionLegacy.Reply }>()
const fiber = yield* ask({ const fiber = yield* ask({
id: PermissionID.make("per_test7"), id: PermissionLegacy.ID.make("per_test7"),
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -935,13 +935,13 @@ it.instance(
if (event.type === Permission.Event.Replied.type) if (event.type === Permission.Event.Replied.type)
Deferred.doneUnsafe( Deferred.doneUnsafe(
seen, seen,
Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }), Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionLegacy.ID; reply: PermissionLegacy.Reply }),
) )
return Effect.void return Effect.void
}) })
yield* Effect.addFinalizer(() => unsub) yield* Effect.addFinalizer(() => unsub)
yield* reply({ requestID: PermissionID.make("per_test7"), reply: "once" }) yield* reply({ requestID: PermissionLegacy.ID.make("per_test7"), reply: "once" })
yield* Fiber.join(fiber) yield* Fiber.join(fiber)
expect( expect(
yield* Deferred.await(seen).pipe( yield* Deferred.await(seen).pipe(
@@ -952,7 +952,7 @@ it.instance(
), ),
).toEqual({ ).toEqual({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
requestID: PermissionID.make("per_test7"), requestID: PermissionLegacy.ID.make("per_test7"),
reply: "once", reply: "once",
}) })
}), }),
@@ -969,7 +969,7 @@ it.live("permission requests stay isolated by directory", () =>
.provide( .provide(
{ directory: one }, { directory: one },
ask({ ask({
id: PermissionID.make("per_dir_a"), id: PermissionLegacy.ID.make("per_dir_a"),
sessionID: SessionID.make("session_dir_a"), sessionID: SessionID.make("session_dir_a"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -984,7 +984,7 @@ it.live("permission requests stay isolated by directory", () =>
.provide( .provide(
{ directory: two }, { directory: two },
ask({ ask({
id: PermissionID.make("per_dir_b"), id: PermissionLegacy.ID.make("per_dir_b"),
sessionID: SessionID.make("session_dir_b"), sessionID: SessionID.make("session_dir_b"),
permission: "bash", permission: "bash",
patterns: ["pwd"], patterns: ["pwd"],
@@ -1000,8 +1000,8 @@ it.live("permission requests stay isolated by directory", () =>
expect(onePending).toHaveLength(1) expect(onePending).toHaveLength(1)
expect(twoPending).toHaveLength(1) expect(twoPending).toHaveLength(1)
expect(onePending[0].id).toBe(PermissionID.make("per_dir_a")) expect(onePending[0].id).toBe(PermissionLegacy.ID.make("per_dir_a"))
expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b")) expect(twoPending[0].id).toBe(PermissionLegacy.ID.make("per_dir_b"))
yield* store.provide({ directory: one }, reply({ requestID: onePending[0].id, reply: "reject" })) yield* store.provide({ directory: one }, reply({ requestID: onePending[0].id, reply: "reject" }))
yield* store.provide({ directory: two }, reply({ requestID: twoPending[0].id, reply: "reject" })) yield* store.provide({ directory: two }, reply({ requestID: twoPending[0].id, reply: "reject" }))
@@ -1018,7 +1018,7 @@ it.instance(
const test = yield* TestInstance const test = yield* TestInstance
const store = yield* InstanceStore.Service const store = yield* InstanceStore.Service
const fiber = yield* ask({ const fiber = yield* ask({
id: PermissionID.make("per_dispose"), id: PermissionLegacy.ID.make("per_dispose"),
sessionID: SessionID.make("session_dispose"), sessionID: SessionID.make("session_dispose"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -1033,7 +1033,7 @@ it.instance(
const exit = yield* Fiber.await(fiber) const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}), }),
{ git: true }, { git: true },
) )
@@ -1045,7 +1045,7 @@ it.instance(
const test = yield* TestInstance const test = yield* TestInstance
const store = yield* InstanceStore.Service const store = yield* InstanceStore.Service
const fiber = yield* ask({ const fiber = yield* ask({
id: PermissionID.make("per_reload"), id: PermissionLegacy.ID.make("per_reload"),
sessionID: SessionID.make("session_reload"), sessionID: SessionID.make("session_reload"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -1059,7 +1059,7 @@ it.instance(
const exit = yield* Fiber.await(fiber) const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}), }),
{ git: true }, { git: true },
) )
@@ -1068,7 +1068,7 @@ it.instance(
"reply - fails for unknown requestID", "reply - fails for unknown requestID",
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" }).pipe(Effect.exit) const exit = yield* reply({ requestID: PermissionLegacy.ID.make("per_unknown"), reply: "once" }).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) { if (Exit.isFailure(exit)) {
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Permission.NotFoundError", requestID: "per_unknown" }) expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Permission.NotFoundError", requestID: "per_unknown" })
@@ -1095,7 +1095,7 @@ it.instance(
], ],
}), }),
) )
expect(err).toBeInstanceOf(Permission.DeniedError) expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
}), }),
{ git: true }, { git: true },
) )
@@ -1135,7 +1135,7 @@ it.instance(
}), }),
) )
expect(err).toBeInstanceOf(Permission.DeniedError) expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
expect(yield* list()).toHaveLength(0) expect(yield* list()).toHaveLength(0)
}), }),
{ git: true }, { git: true },
@@ -1149,7 +1149,7 @@ it.instance(
const store = yield* InstanceStore.Service const store = yield* InstanceStore.Service
const fiber = yield* ask({ const fiber = yield* ask({
id: PermissionID.make("per_reload"), id: PermissionLegacy.ID.make("per_reload"),
sessionID: SessionID.make("session_reload"), sessionID: SessionID.make("session_reload"),
permission: "bash", permission: "bash",
patterns: ["ls"], patterns: ["ls"],
@@ -1164,7 +1164,7 @@ it.instance(
const exit = yield* Fiber.await(fiber) const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionLegacy.RejectedError)
}), }),
{ git: true }, { git: true },
) )
+1 -19
View File
@@ -8,7 +8,7 @@ import { tmpdirScoped } from "../fixture/fixture"
import { GlobalBus } from "../../src/bus/global" import { GlobalBus } from "../../src/bus/global"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql" 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 { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Hash } from "@opencode-ai/core/util/hash" import { Hash } from "@opencode-ai/core/util/hash"
@@ -218,16 +218,6 @@ describe("Project.fromDirectory", () => {
}) })
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* db
.insert(PermissionTable)
.values({
project_id: rootProject.id,
data: [{ permission: "edit", pattern: "*", action: "allow" }],
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
.pipe(Effect.orDie)
yield* db yield* db
.insert(WorkspaceTable) .insert(WorkspaceTable)
.values({ id: workspaceID, type: "local", name: "test", project_id: rootProject.id }) .values({ id: workspaceID, type: "local", name: "test", project_id: rootProject.id })
@@ -245,14 +235,6 @@ describe("Project.fromDirectory", () => {
(yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)) (yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie))
?.project_id, ?.project_id,
).toBe(remoteID) ).toBe(remoteID)
expect(
yield* db
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, remoteID))
.get()
.pipe(Effect.orDie),
).toBeDefined()
expect( expect(
(yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie)) (yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie))
?.project_id, ?.project_id,
@@ -579,6 +579,32 @@ const scenarios: Scenario[] = [
.get("/api/provider/{providerID}", "v2.provider.get") .get("/api/provider/{providerID}", "v2.provider.get")
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() })) .at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
.json(404, object, "status"), .json(404, object, "status"),
http.protected.get("/api/permission/request", "v2.permission.request.list").json(200, array),
http.protected
.get("/api/session/{sessionID}/permission/request", "v2.session.permission.list")
.seeded((ctx) => ctx.session({ title: "Permission list owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/permission/request", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, array),
http.protected
.post("/api/session/{sessionID}/permission/request/{requestID}/reply", "v2.session.permission.reply")
.seeded((ctx) => ctx.session({ title: "Permission owner" }))
.at((ctx) => ({
path: route("/api/session/{sessionID}/permission/request/{requestID}/reply", {
sessionID: ctx.state.id,
requestID: "per_httpapi_missing",
}),
headers: ctx.headers(),
body: { reply: "once" },
}))
.json(404, object, "status"),
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, array),
http.protected
.delete("/api/permission/saved/{id}", "v2.permission.saved.remove")
.at((ctx) => ({ path: route("/api/permission/saved/{id}", { id: "psv_httpapi_missing" }), headers: ctx.headers() }))
.status(204, undefined, "status"),
http.protected http.protected
.get("/api/session", "v2.session.list") .get("/api/session", "v2.session.list")
.at((ctx) => ({ path: "/api/session?roots=true", headers: ctx.headers() })) .at((ctx) => ({ path: "/api/session?roots=true", headers: ctx.headers() }))
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag" import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
@@ -8,7 +9,6 @@ import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { PermissionID } from "../../src/permission/schema"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
import { QuestionID } from "../../src/question/schema" import { QuestionID } from "../../src/question/schema"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
@@ -167,7 +167,7 @@ describe("instance HttpApi", () => {
handlerContext, handlerContext,
), ),
) )
const permissionID = PermissionID.ascending() const permissionID = PermissionLegacy.ID.ascending()
const questionReplyID = QuestionID.ascending() const questionReplyID = QuestionID.ascending()
const questionRejectID = QuestionID.ascending() const questionRejectID = QuestionID.ascending()
const [permission, questionReply, questionReject] = yield* Effect.all( const [permission, questionReply, questionReject] = yield* Effect.all(
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { afterEach, describe, expect } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { SessionLegacy } from "@opencode-ai/core/session/legacy"
@@ -11,7 +12,6 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters" import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types" import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace" import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service" import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service"
@@ -913,7 +913,7 @@ describe("session HttpApi", () => {
}), }),
).toMatchObject({ id: session.id }) ).toMatchObject({ id: session.id })
const permissionID = String(PermissionID.ascending()) const permissionID = String(PermissionLegacy.ID.ascending())
const permission = yield* request( const permission = yield* request(
pathFor(SessionPaths.permissions, { pathFor(SessionPaths.permissions, {
sessionID: session.id, sessionID: session.id,
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
import { SessionLegacy } from "@opencode-ai/core/session/legacy" import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import path from "path" import path from "path"
@@ -332,7 +333,7 @@ describe("session.llm.ai-sdk adapter", () => {
}) })
test("preserves tool-error cause", async () => { test("preserves tool-error cause", async () => {
const error = new Permission.RejectedError() const error = new PermissionLegacy.RejectedError()
const events = await Effect.runPromise( const events = await Effect.runPromise(
LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), { LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), {
type: "tool-error", type: "tool-error",
@@ -10,7 +10,7 @@ import { Global } from "@opencode-ai/core/global"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
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 { SessionShareTable } from "@opencode-ai/core/share/sql"
import { SessionID, MessageID, PartID } from "../../src/session/schema" import { SessionID, MessageID, PartID } from "../../src/session/schema"
@@ -574,7 +574,7 @@ describe("JSON to SQLite migration", () => {
expect(todos[2].position).toBe(2) expect(todos[2].position).toBe(2)
}) })
test("migrates permissions", async () => { test("does not migrate legacy permissions", async () => {
await writeProject(storageDir, { await writeProject(storageDir, {
id: "proj_test123abc", id: "proj_test123abc",
worktree: "/", worktree: "/",
@@ -592,12 +592,7 @@ describe("JSON to SQLite migration", () => {
const stats = await JsonMigration.run(db) const stats = await JsonMigration.run(db)
expect(stats?.permissions).toBe(1) expect(stats?.permissions).toBe(0)
const permissions = db.select().from(PermissionTable).all()
expect(permissions.length).toBe(1)
expect(permissions[0].project_id).toBe("proj_test123abc")
expect(permissions[0].data).toEqual(permissionData)
}) })
test("migrates session shares", async () => { test("migrates session shares", async () => {
@@ -694,7 +689,7 @@ describe("JSON to SQLite migration", () => {
expect(todos[1].position).toBe(2) expect(todos[1].position).toBe(2)
}) })
test("skips orphaned todos, permissions, and shares", async () => { test("skips orphaned todos and shares", async () => {
await writeProject(storageDir, { await writeProject(storageDir, {
id: "proj_test123abc", id: "proj_test123abc",
worktree: "/", worktree: "/",
@@ -733,11 +728,10 @@ describe("JSON to SQLite migration", () => {
const stats = await JsonMigration.run(db) const stats = await JsonMigration.run(db)
expect(stats.todos).toBe(1) expect(stats.todos).toBe(1)
expect(stats.permissions).toBe(1) expect(stats.permissions).toBe(0)
expect(stats.shares).toBe(1) expect(stats.shares).toBe(1)
expect(db.select().from(TodoTable).all().length).toBe(1) expect(db.select().from(TodoTable).all().length).toBe(1)
expect(db.select().from(PermissionTable).all().length).toBe(1)
expect(db.select().from(SessionShareTable).all().length).toBe(1) expect(db.select().from(SessionShareTable).all().length).toBe(1)
}) })
@@ -848,7 +842,7 @@ describe("JSON to SQLite migration", () => {
expect(stats.messages).toBe(1) expect(stats.messages).toBe(1)
expect(stats.parts).toBe(1) expect(stats.parts).toBe(1)
expect(stats.todos).toBe(1) expect(stats.todos).toBe(1)
expect(stats.permissions).toBe(1) expect(stats.permissions).toBe(0)
expect(stats.shares).toBe(1) expect(stats.shares).toBe(1)
expect(stats.errors.length).toBeGreaterThanOrEqual(6) expect(stats.errors.length).toBeGreaterThanOrEqual(6)
@@ -857,7 +851,6 @@ describe("JSON to SQLite migration", () => {
expect(db.select().from(MessageTable).all().length).toBe(1) expect(db.select().from(MessageTable).all().length).toBe(1)
expect(db.select().from(PartTable).all().length).toBe(1) expect(db.select().from(PartTable).all().length).toBe(1)
expect(db.select().from(TodoTable).all().length).toBe(1) expect(db.select().from(TodoTable).all().length).toBe(1)
expect(db.select().from(PermissionTable).all().length).toBe(1)
expect(db.select().from(SessionShareTable).all().length).toBe(1) expect(db.select().from(SessionShareTable).all().length).toBe(1)
}) })
}) })
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import path from "path" import path from "path"
import { Effect } from "effect" import { Effect } from "effect"
@@ -26,7 +27,7 @@ const glob = (p: string) =>
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/") process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
function makeCtx() { function makeCtx() {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = { const ctx: Tool.Context = {
...baseCtx, ...baseCtx,
ask: (req) => ask: (req) =>
+3 -2
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import path from "path" import path from "path"
import { Cause, Effect, Exit, Layer } from "effect" import { Cause, Effect, Exit, Layer } from "effect"
@@ -52,12 +53,12 @@ const ctx = {
} }
const asks = () => { const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const items: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
return { return {
items, items,
next: { next: {
...ctx, ...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => { Effect.sync(() => {
items.push(req) items.push(req)
}), }),
+3 -2
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import os from "os" import os from "os"
@@ -186,7 +187,7 @@ describe("tool.grep", () => {
[path.join(alias, "*")]: "allow", [path.join(alias, "*")]: "allow",
}, },
}) })
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const next: Tool.Context = { const next: Tool.Context = {
...ctx, ...ctx,
ask: (req) => ask: (req) =>
@@ -234,7 +235,7 @@ describe("tool.grep", () => {
yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie) yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo]) yield* git(remoteRoot, ["clone", "--bare", source, remoteRepo])
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const next: Tool.Context = { const next: Tool.Context = {
...ctx, ...ctx,
ask: (req) => ask: (req) =>
+3 -2
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { afterEach, describe, expect } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import path from "path" import path from "path"
@@ -83,12 +84,12 @@ const put = Effect.fn("LspToolTest.put")(function* (file: string) {
}) })
const asks = () => { const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const items: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
return { return {
items, items,
next: { next: {
...ctx, ...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => { Effect.sync(() => {
items.push(req) items.push(req)
}), }),
+5 -4
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { afterEach, describe, expect } from "bun:test" import { afterEach, describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer, Stream } from "effect" import { Cause, Effect, Exit, Layer, Stream } from "effect"
import path from "path" import path from "path"
@@ -140,12 +141,12 @@ const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
return yield* fs.readFileString(p) return yield* fs.readFileString(p)
}) })
const asks = () => { const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const items: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
return { return {
items, items,
next: { next: {
...ctx, ...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => { Effect.sync(() => {
items.push(req) items.push(req)
}), }),
@@ -328,7 +329,7 @@ describe("tool.read env file permissions", () => {
let asked = false let asked = false
const next = { const next = {
...ctx, ...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => { Effect.sync(() => {
for (const pattern of req.patterns) { for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, info.permission) const rule = Permission.evaluate(req.permission, pattern, info.permission)
@@ -336,7 +337,7 @@ describe("tool.read env file permissions", () => {
asked = true asked = true
} }
if (rule.action === "deny") { if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset: info.permission }) throw new PermissionLegacy.DeniedError({ ruleset: info.permission })
} }
} }
}), }),
+33 -32
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect" import { Cause, Effect, Exit, Layer } from "effect"
import type * as Scope from "effect/Scope" import type * as Scope from "effect/Scope"
@@ -155,9 +156,9 @@ const each = (
} }
} }
const capture = (requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({ const capture = (requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">>, stop?: Error) => ({
...ctx, ...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => { Effect.sync(() => {
requests.push(req) requests.push(req)
if (stop) throw stop if (stop) throw stop
@@ -222,7 +223,7 @@ describe("tool.shell permissions", () => {
yield* runIn( yield* runIn(
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run( yield* run(
{ {
command: "echo hello", command: "echo hello",
@@ -244,7 +245,7 @@ describe("tool.shell permissions", () => {
yield* runIn( yield* runIn(
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run( yield* run(
{ {
command: "echo foo && echo bar", command: "echo foo && echo bar",
@@ -268,7 +269,7 @@ describe("tool.shell permissions", () => {
runIn( runIn(
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run( yield* run(
{ {
command: "Write-Host foo; if ($?) { Write-Host bar }", command: "Write-Host foo; if ($?) { Write-Host bar }",
@@ -297,7 +298,7 @@ describe("tool.shell permissions", () => {
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect( expect(
yield* fail( yield* fail(
{ {
@@ -323,7 +324,7 @@ describe("tool.shell permissions", () => {
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*"
const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*" const want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*"
expect( expect(
@@ -354,7 +355,7 @@ describe("tool.shell permissions", () => {
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const file = path.join(outerTmp, "outside.txt").replaceAll("\\", "/") const file = path.join(outerTmp, "outside.txt").replaceAll("\\", "/")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run( yield* run(
{ {
command: `echo $(cat "${file}")`, command: `echo $(cat "${file}")`,
@@ -383,7 +384,7 @@ describe("tool.shell permissions", () => {
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect( expect(
yield* fail( yield* fail(
{ {
@@ -409,7 +410,7 @@ describe("tool.shell permissions", () => {
runIn( runIn(
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini`
yield* run( yield* run(
{ {
@@ -440,7 +441,7 @@ describe("tool.shell permissions", () => {
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect( expect(
yield* fail( yield* fail(
{ {
@@ -468,7 +469,7 @@ describe("tool.shell permissions", () => {
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect( expect(
yield* fail( yield* fail(
{ {
@@ -497,7 +498,7 @@ describe("tool.shell permissions", () => {
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect( expect(
yield* fail( yield* fail(
{ {
@@ -525,7 +526,7 @@ describe("tool.shell permissions", () => {
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect( expect(
yield* fail( yield* fail(
{ {
@@ -560,7 +561,7 @@ describe("tool.shell permissions", () => {
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "")
expect( expect(
yield* fail( yield* fail(
@@ -593,7 +594,7 @@ describe("tool.shell permissions", () => {
runIn( runIn(
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run( yield* run(
{ {
command: "Get-Content $env:WINDIR/win.ini", command: "Get-Content $env:WINDIR/win.ini",
@@ -620,7 +621,7 @@ describe("tool.shell permissions", () => {
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect( expect(
yield* fail( yield* fail(
{ {
@@ -649,7 +650,7 @@ describe("tool.shell permissions", () => {
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect( expect(
yield* fail( yield* fail(
{ {
@@ -677,7 +678,7 @@ describe("tool.shell permissions", () => {
runIn( runIn(
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run( yield* run(
{ {
command: "Set-Location C:/Windows", command: "Set-Location C:/Windows",
@@ -705,7 +706,7 @@ describe("tool.shell permissions", () => {
runIn( runIn(
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run( yield* run(
{ {
command: "Write-Output ('a' * 3)", command: "Write-Output ('a' * 3)",
@@ -731,7 +732,7 @@ describe("tool.shell permissions", () => {
runIn( runIn(
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run( yield* run(
{ {
command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`,
@@ -755,7 +756,7 @@ describe("tool.shell permissions", () => {
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect( expect(
yield* fail( yield* fail(
{ {
@@ -779,7 +780,7 @@ describe("tool.shell permissions", () => {
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect( expect(
yield* fail( yield* fail(
{ {
@@ -810,7 +811,7 @@ describe("tool.shell permissions", () => {
const want = Filesystem.normalizePathPattern(path.join(outerTmp, "*")) const want = Filesystem.normalizePathPattern(path.join(outerTmp, "*"))
for (const dir of forms(outerTmp)) { for (const dir of forms(outerTmp)) {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect( expect(
yield* fail( yield* fail(
{ {
@@ -842,7 +843,7 @@ describe("tool.shell permissions", () => {
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const want = glob(path.join(os.tmpdir(), "*")) const want = glob(path.join(os.tmpdir(), "*"))
expect( expect(
yield* fail( yield* fail(
@@ -871,7 +872,7 @@ describe("tool.shell permissions", () => {
projectRoot, projectRoot,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const want = glob(path.join(os.tmpdir(), "*")) const want = glob(path.join(os.tmpdir(), "*"))
expect( expect(
yield* fail( yield* fail(
@@ -903,7 +904,7 @@ describe("tool.shell permissions", () => {
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const filepath = path.join(outerTmp, "outside.txt") const filepath = path.join(outerTmp, "outside.txt")
expect( expect(
yield* fail( yield* fail(
@@ -931,7 +932,7 @@ describe("tool.shell permissions", () => {
yield* runIn( yield* runIn(
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run( yield* run(
{ {
command: `rm -rf ${path.join(tmp, "nested")}`, command: `rm -rf ${path.join(tmp, "nested")}`,
@@ -952,7 +953,7 @@ describe("tool.shell permissions", () => {
yield* runIn( yield* runIn(
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run( yield* run(
{ {
command: "git log --oneline -5", command: "git log --oneline -5",
@@ -974,7 +975,7 @@ describe("tool.shell permissions", () => {
yield* runIn( yield* runIn(
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run( yield* run(
{ {
command: "cd .", command: "cd .",
@@ -996,7 +997,7 @@ describe("tool.shell permissions", () => {
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const err = new Error("stop after permission") const err = new Error("stop after permission")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
expect( expect(
yield* fail( yield* fail(
{ command: "echo test > output.txt", description: "Redirect test output" }, { command: "echo test > output.txt", description: "Redirect test output" },
@@ -1017,7 +1018,7 @@ describe("tool.shell permissions", () => {
yield* runIn( yield* runIn(
tmp, tmp,
Effect.gen(function* () { Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run({ command: "ls -la", description: "List" }, capture(requests)) yield* run({ command: "ls -la", description: "List" }, capture(requests))
const bashReq = requests.find((r) => r.permission === "bash") const bashReq = requests.find((r) => r.permission === "bash")
expect(bashReq).toBeDefined() expect(bashReq).toBeDefined()
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Cause, Effect, Exit, Layer } from "effect" import { Cause, Effect, Exit, Layer } from "effect"
import { afterEach, describe, expect } from "bun:test" import { afterEach, describe, expect } from "bun:test"
@@ -67,7 +68,7 @@ Use this skill.
})).find((tool) => tool.id === SkillTool.id) })).find((tool) => tool.id === SkillTool.id)
if (!tool) throw new Error("Skill tool not found") if (!tool) throw new Error("Skill tool not found")
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = { const ctx: Tool.Context = {
...baseCtx, ...baseCtx,
ask: (req) => ask: (req) =>
+177
View File
@@ -117,6 +117,7 @@ import type {
PermissionRespondErrors, PermissionRespondErrors,
PermissionRespondResponses, PermissionRespondResponses,
PermissionRuleset, PermissionRuleset,
PermissionV2Reply,
ProjectCurrentErrors, ProjectCurrentErrors,
ProjectCurrentResponses, ProjectCurrentResponses,
ProjectInitGitErrors, ProjectInitGitErrors,
@@ -248,6 +249,12 @@ import type {
TuiSubmitPromptResponses, TuiSubmitPromptResponses,
V2ModelListErrors, V2ModelListErrors,
V2ModelListResponses, V2ModelListResponses,
V2PermissionRequestListErrors,
V2PermissionRequestListResponses,
V2PermissionSavedListErrors,
V2PermissionSavedListResponses,
V2PermissionSavedRemoveErrors,
V2PermissionSavedRemoveResponses,
V2ProviderGetErrors, V2ProviderGetErrors,
V2ProviderGetResponses, V2ProviderGetResponses,
V2ProviderListErrors, V2ProviderListErrors,
@@ -260,6 +267,10 @@ import type {
V2SessionListResponses, V2SessionListResponses,
V2SessionMessagesErrors, V2SessionMessagesErrors,
V2SessionMessagesResponses, V2SessionMessagesResponses,
V2SessionPermissionListErrors,
V2SessionPermissionListResponses,
V2SessionPermissionReplyErrors,
V2SessionPermissionReplyResponses,
V2SessionPromptErrors, V2SessionPromptErrors,
V2SessionPromptResponses, V2SessionPromptResponses,
V2SessionWaitErrors, V2SessionWaitErrors,
@@ -4255,6 +4266,74 @@ export class Sync extends HeyApiClient {
} }
} }
export class Permission2 extends HeyApiClient {
/**
* List session permission requests
*
* Retrieve pending permission requests owned by a session.
*/
public list<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
return (options?.client ?? this.client).get<
V2SessionPermissionListResponses,
V2SessionPermissionListErrors,
ThrowOnError
>({
url: "/api/session/{sessionID}/permission/request",
...options,
...params,
})
}
/**
* Reply to pending permission request
*
* Respond to a pending permission request owned by a session.
*/
public reply<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
requestID: string
reply?: PermissionV2Reply
message?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "sessionID" },
{ in: "path", key: "requestID" },
{ in: "body", key: "reply" },
{ in: "body", key: "message" },
],
},
],
)
return (options?.client ?? this.client).post<
V2SessionPermissionReplyResponses,
V2SessionPermissionReplyErrors,
ThrowOnError
>({
url: "/api/session/{sessionID}/permission/request/{requestID}/reply",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Session3 extends HeyApiClient { export class Session3 extends HeyApiClient {
/** /**
* List v2 sessions * List v2 sessions
@@ -4474,6 +4553,11 @@ export class Session3 extends HeyApiClient {
...params, ...params,
}) })
} }
private _permission?: Permission2
get permission(): Permission2 {
return (this._permission ??= new Permission2({ client: this.client }))
}
} }
export class Model extends HeyApiClient { export class Model extends HeyApiClient {
@@ -4557,6 +4641,94 @@ export class Provider2 extends HeyApiClient {
} }
} }
export class Request extends HeyApiClient {
/**
* List pending permission requests
*
* Retrieve pending permission requests for a location.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
location?: {
directory?: string
workspace?: string
}
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
return (options?.client ?? this.client).get<
V2PermissionRequestListResponses,
V2PermissionRequestListErrors,
ThrowOnError
>({
url: "/api/permission/request",
...options,
...params,
})
}
}
export class Saved extends HeyApiClient {
/**
* List saved permissions
*
* Retrieve saved permissions, optionally filtered by project.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
projectID?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "projectID" }] }])
return (options?.client ?? this.client).get<
V2PermissionSavedListResponses,
V2PermissionSavedListErrors,
ThrowOnError
>({
url: "/api/permission/saved",
...options,
...params,
})
}
/**
* Remove saved permission
*
* Remove a saved permission by ID.
*/
public remove<ThrowOnError extends boolean = false>(
parameters: {
id: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "id" }] }])
return (options?.client ?? this.client).delete<
V2PermissionSavedRemoveResponses,
V2PermissionSavedRemoveErrors,
ThrowOnError
>({
url: "/api/permission/saved/{id}",
...options,
...params,
})
}
}
export class Permission3 extends HeyApiClient {
private _request?: Request
get request(): Request {
return (this._request ??= new Request({ client: this.client }))
}
private _saved?: Saved
get saved(): Saved {
return (this._saved ??= new Saved({ client: this.client }))
}
}
export class V2 extends HeyApiClient { export class V2 extends HeyApiClient {
private _session?: Session3 private _session?: Session3
get session(): Session3 { get session(): Session3 {
@@ -4572,6 +4744,11 @@ export class V2 extends HeyApiClient {
get provider(): Provider2 { get provider(): Provider2 {
return (this._provider ??= new Provider2({ client: this.client })) return (this._provider ??= new Provider2({ client: this.client }))
} }
private _permission?: Permission3
get permission(): Permission3 {
return (this._permission ??= new Permission3({ client: this.client }))
}
} }
export class Control extends HeyApiClient { export class Control extends HeyApiClient {
+310 -60
View File
@@ -44,10 +44,10 @@ export type Event =
| EventMessagePartUpdated | EventMessagePartUpdated
| EventMessagePartRemoved | EventMessagePartRemoved
| EventMessagePartDelta | EventMessagePartDelta
| EventPermissionAsked
| EventPermissionReplied
| EventSessionDiff | EventSessionDiff
| EventSessionError | EventSessionError
| EventPermissionAsked
| EventPermissionReplied
| EventQuestionAsked | EventQuestionAsked
| EventQuestionReplied | EventQuestionReplied
| EventQuestionRejected | EventQuestionRejected
@@ -78,6 +78,8 @@ export type Event =
| EventInstallationUpdateAvailable | EventInstallationUpdateAvailable
| EventServerConnected | EventServerConnected
| EventGlobalDisposed | EventGlobalDisposed
| EventPermissionV2Asked
| EventPermissionV2Replied
| EventAccountAdded | EventAccountAdded
| EventAccountRemoved | EventAccountRemoved
| EventAccountSwitched | EventAccountSwitched
@@ -145,6 +147,16 @@ export type SnapshotFileDiff = {
status?: "added" | "deleted" | "modified" status?: "added" | "deleted" | "modified"
} }
export type PermissionAction = "allow" | "deny" | "ask"
export type PermissionRule = {
permission: string
pattern: string
action: PermissionAction
}
export type PermissionRuleset = Array<PermissionRule>
export type Session = { export type Session = {
id: string id: string
slug: string slug: string
@@ -1094,6 +1106,29 @@ export type GlobalEvent = {
delta: string delta: string
} }
} }
| {
id: string
type: "session.diff"
properties: {
sessionID: string
diff: Array<SnapshotFileDiff>
}
}
| {
id: string
type: "session.error"
properties: {
sessionID?: string
error?:
| ProviderAuthError
| UnknownError
| MessageOutputLengthError
| MessageAbortedError
| StructuredOutputError
| ContextOverflowError
| ApiError
}
}
| { | {
id: string id: string
type: "permission.asked" type: "permission.asked"
@@ -1121,29 +1156,6 @@ export type GlobalEvent = {
reply: "once" | "always" | "reject" reply: "once" | "always" | "reject"
} }
} }
| {
id: string
type: "session.diff"
properties: {
sessionID: string
diff: Array<SnapshotFileDiff>
}
}
| {
id: string
type: "session.error"
properties: {
sessionID?: string
error?:
| ProviderAuthError
| UnknownError
| MessageOutputLengthError
| MessageAbortedError
| StructuredOutputError
| ContextOverflowError
| ApiError
}
}
| { | {
id: string id: string
type: "question.asked" type: "question.asked"
@@ -1415,6 +1427,30 @@ export type GlobalEvent = {
[key: string]: unknown [key: string]: unknown
} }
} }
| {
id: string
type: "permission.v2.asked"
properties: {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
}
| {
id: string
type: "permission.v2.replied"
properties: {
sessionID: string
requestID: string
reply: PermissionV2Reply
}
}
| { | {
id: string id: string
type: "account.added" type: "account.added"
@@ -2029,16 +2065,6 @@ export type WorktreeResetInput = {
directory: string directory: string
} }
export type PermissionAction = "allow" | "deny" | "ask"
export type PermissionRule = {
permission: string
pattern: string
action: PermissionAction
}
export type PermissionRuleset = Array<PermissionRule>
export type ProjectSummary = { export type ProjectSummary = {
id: string id: string
name?: string name?: string
@@ -2811,6 +2837,14 @@ export type SessionNextRetryError = {
} }
} }
export type PermissionV2Source = {
type: "tool"
messageID: string
callID: string
}
export type PermissionV2Reply = "once" | "always" | "reject"
export type AuthOAuthCredential = { export type AuthOAuthCredential = {
type: "oauth" type: "oauth"
refresh: string refresh: string
@@ -3637,6 +3671,25 @@ export type ProviderV2Info = {
} }
} }
export type PermissionV2Request = {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
export type PermissionSavedInfo = {
id: string
projectID: string
action: string
resource: string
}
export type EventModelsDevRefreshed = { export type EventModelsDevRefreshed = {
id: string id: string
type: "models-dev.refreshed" type: "models-dev.refreshed"
@@ -4173,6 +4226,31 @@ export type EventMessagePartDelta = {
} }
} }
export type EventSessionDiff = {
id: string
type: "session.diff"
properties: {
sessionID: string
diff: Array<SnapshotFileDiff>
}
}
export type EventSessionError = {
id: string
type: "session.error"
properties: {
sessionID?: string
error?:
| ProviderAuthError
| UnknownError
| MessageOutputLengthError
| MessageAbortedError
| StructuredOutputError
| ContextOverflowError
| ApiError
}
}
export type EventPermissionAsked = { export type EventPermissionAsked = {
id: string id: string
type: "permission.asked" type: "permission.asked"
@@ -4202,31 +4280,6 @@ export type EventPermissionReplied = {
} }
} }
export type EventSessionDiff = {
id: string
type: "session.diff"
properties: {
sessionID: string
diff: Array<SnapshotFileDiff>
}
}
export type EventSessionError = {
id: string
type: "session.error"
properties: {
sessionID?: string
error?:
| ProviderAuthError
| UnknownError
| MessageOutputLengthError
| MessageAbortedError
| StructuredOutputError
| ContextOverflowError
| ApiError
}
}
export type EventQuestionAsked = { export type EventQuestionAsked = {
id: string id: string
type: "question.asked" type: "question.asked"
@@ -4473,6 +4526,32 @@ export type EventGlobalDisposed = {
} }
} }
export type EventPermissionV2Asked = {
id: string
type: "permission.v2.asked"
properties: {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: {
[key: string]: unknown
}
source?: PermissionV2Source
}
}
export type EventPermissionV2Replied = {
id: string
type: "permission.v2.replied"
properties: {
sessionID: string
requestID: string
reply: PermissionV2Reply
}
}
export type EventAccountAdded = { export type EventAccountAdded = {
id: string id: string
type: "account.added" type: "account.added"
@@ -8262,6 +8341,177 @@ export type V2ProviderGetResponses = {
export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses] export type V2ProviderGetResponse = V2ProviderGetResponses[keyof V2ProviderGetResponses]
export type V2PermissionRequestListData = {
body?: never
path?: never
query?: {
location?: {
directory?: string
workspace?: string
}
}
url: "/api/permission/request"
}
export type V2PermissionRequestListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2PermissionRequestListError = V2PermissionRequestListErrors[keyof V2PermissionRequestListErrors]
export type V2PermissionRequestListResponses = {
/**
* Success
*/
200: Array<PermissionV2Request>
}
export type V2PermissionRequestListResponse = V2PermissionRequestListResponses[keyof V2PermissionRequestListResponses]
export type V2SessionPermissionListData = {
body?: never
path: {
sessionID: string
}
query?: never
url: "/api/session/{sessionID}/permission/request"
}
export type V2SessionPermissionListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* SessionNotFoundError
*/
404: SessionNotFoundError
}
export type V2SessionPermissionListError = V2SessionPermissionListErrors[keyof V2SessionPermissionListErrors]
export type V2SessionPermissionListResponses = {
/**
* Success
*/
200: Array<PermissionV2Request>
}
export type V2SessionPermissionListResponse = V2SessionPermissionListResponses[keyof V2SessionPermissionListResponses]
export type V2SessionPermissionReplyData = {
body?: {
reply: PermissionV2Reply
message?: string
}
path: {
sessionID: string
requestID: string
}
query?: never
url: "/api/session/{sessionID}/permission/request/{requestID}/reply"
}
export type V2SessionPermissionReplyErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
/**
* SessionNotFoundError | PermissionNotFoundError
*/
404: SessionNotFoundError | PermissionNotFoundError
}
export type V2SessionPermissionReplyError = V2SessionPermissionReplyErrors[keyof V2SessionPermissionReplyErrors]
export type V2SessionPermissionReplyResponses = {
/**
* <No Content>
*/
204: void
}
export type V2SessionPermissionReplyResponse =
V2SessionPermissionReplyResponses[keyof V2SessionPermissionReplyResponses]
export type V2PermissionSavedListData = {
body?: never
path?: never
query?: {
projectID?: string
}
url: "/api/permission/saved"
}
export type V2PermissionSavedListErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2PermissionSavedListError = V2PermissionSavedListErrors[keyof V2PermissionSavedListErrors]
export type V2PermissionSavedListResponses = {
/**
* Success
*/
200: Array<PermissionSavedInfo>
}
export type V2PermissionSavedListResponse = V2PermissionSavedListResponses[keyof V2PermissionSavedListResponses]
export type V2PermissionSavedRemoveData = {
body?: never
path: {
id: string
}
query?: never
url: "/api/permission/saved/{id}"
}
export type V2PermissionSavedRemoveErrors = {
/**
* InvalidRequestError
*/
400: InvalidRequestError
/**
* UnauthorizedError
*/
401: UnauthorizedError
}
export type V2PermissionSavedRemoveError = V2PermissionSavedRemoveErrors[keyof V2PermissionSavedRemoveErrors]
export type V2PermissionSavedRemoveResponses = {
/**
* <No Content>
*/
204: void
}
export type V2PermissionSavedRemoveResponse = V2PermissionSavedRemoveResponses[keyof V2PermissionSavedRemoveResponses]
export type TuiAppendPromptData = { export type TuiAppendPromptData = {
body?: { body?: {
text: string text: string