Compare commits

..

14 Commits

Author SHA1 Message Date
opencode-agent[bot] 497ff36e91 chore: generate 2026-06-02 04:14:16 +00:00
Dax Raad c1c02f80dd feat(core): expose session location 2026-06-02 00:12:42 -04:00
opencode-agent[bot] bbec00fbd5 chore: generate 2026-06-02 03:28:04 +00:00
Aiden Cline 4668db8fa2 fix(opencode): remove sunsetted gpt-5.2 and gpt-5.3-codex from allowed models for codex subscriptions (#30316) 2026-06-01 22:20:44 -05:00
Luke Parker ab69f41067 fix(app): avoid suspending on pending child path (#30314) 2026-06-02 03:19:56 +00:00
opencode-agent[bot] 687c66248c chore: update nix node_modules hashes 2026-06-02 02:54:52 +00:00
Luke Parker 61b5046f88 fix: export v2 stylesheets and declare core node types (#30312) 2026-06-02 12:37:50 +10:00
Luke Parker 4e70eabbcc fix(app): restore deferred MCP status updates (#30220) 2026-06-02 12:11:11 +10:00
Kit Langton 1cae8f89c5 fix(tui): preserve live parts during session hydration (#30300) 2026-06-01 21:40:29 -04:00
opencode-agent[bot] 8dc2ffd48f chore: generate 2026-06-02 01:34:11 +00:00
Dax 9b815bcbd2 feat(core): add location-based permission service (#30287) 2026-06-02 01:32:50 +00:00
opencode-agent[bot] acd620f411 chore: generate 2026-06-02 00:56:03 +00:00
Luke Parker f0c7febb02 fix(opencode): enforce storage path invariants (#29666) 2026-06-02 00:54:41 +00:00
opencode-agent[bot] a821029258 chore: generate 2026-06-01 21:42:43 +00:00
91 changed files with 8127 additions and 1573 deletions
+2
View File
@@ -0,0 +1,2 @@
packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
+1
View File
@@ -290,6 +290,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-51jxaHLvv2Staz9NN9N4EYoNmr2fZeDvfKZ5enf/Wx0=",
"aarch64-linux": "sha256-oGMMlgSJx7Yw5qN6LOquCD/K8GPLy4kDo34AOJGmcso=",
"aarch64-darwin": "sha256-j7IvnyY8Cj4a509D85i+FgfsyQiBstxagvVWMp6y5hI=",
"x86_64-darwin": "sha256-Dd36AN6LopLNV79d7i9lGz5kKOuv6mk1YyBlmdcIhZY="
"x86_64-linux": "sha256-BZR0H2ZmkYNm7g0bx2Vm/15k1jZbtBQLuIkmZbrUepM=",
"aarch64-linux": "sha256-0WVQV0i95AZXtCm5/3txmINTAtenplnPH4A49oZ51aE=",
"aarch64-darwin": "sha256-ELjSy1HfKSk+VZaOIxgcZl1Imnga8rkKZjKu32bzzvg=",
"x86_64-darwin": "sha256-cwXZNYAiOXq63/gAuqJtSSptZUA7cTficVWINAw8iTM="
}
}
@@ -6,7 +6,7 @@ import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const queryGroups: Array<() => { queries: Array<{ enabled?: boolean }> }> = []
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@@ -49,14 +49,20 @@ beforeAll(async () => {
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
}))
mock.module("@tanstack/solid-query", () => ({
useQueries: (options: () => { queries: Array<{ enabled?: boolean }> }) => {
queryGroups.push(options)
return [
{ isLoading: true, data: undefined },
{ isLoading: false, data: {} },
{ isLoading: false, data: [] },
{ isLoading: false, data: provider },
]
useQuery: (options: () => { queryKey?: unknown[]; enabled?: boolean }) => {
querySingles.push(options)
return {
get isLoading() {
return options().queryKey?.[1] === "path"
},
get data() {
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
if (options().queryKey?.[1] === "lsp") return []
if (options().queryKey?.[1] === "providers") return provider
return undefined
},
}
},
}))
@@ -159,7 +165,7 @@ describe("createChildStoreManager", () => {
test("enables MCP only when requested for the directory", () => {
let manager: ReturnType<typeof createChildStoreManager> | undefined
const offset = queryGroups.length
const offset = querySingles.length
const mcpLoads: string[] = []
const dispose = createOwner((owner) => {
@@ -180,18 +186,20 @@ describe("createChildStoreManager", () => {
try {
if (!manager) throw new Error("manager required")
const [, setStore] = manager.child("/project", { bootstrap: false })
const queries = queryGroups[offset]
if (!queries) throw new Error("queries required")
expect(queries().queries[1]?.enabled).toBe(false)
const [store, setStore] = manager.child("/project", { bootstrap: false })
expect(querySingles.length - offset).toBe(4)
const query = querySingles[offset + 1]
if (!query) throw new Error("query required")
expect(query().enabled).toBe(false)
setStore("status", "complete")
manager.child("/project", { bootstrap: false, mcp: true })
expect(queries().queries[1]?.enabled).toBe(true)
expect(query().enabled).toBe(true)
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
expect(mcpLoads).toEqual(["/project"])
manager.disableMcp("/project")
expect(queries().queries[1]?.enabled).toBe(false)
expect(query().enabled).toBe(false)
expect(manager.mcp("/project")).toBe(false)
} finally {
dispose()
@@ -14,7 +14,7 @@ import {
type VcsCache,
} from "./types"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQueries } from "@tanstack/solid-query"
import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
@@ -180,14 +180,10 @@ export function createChildStoreManager(input: {
const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
queries: [
input.queryOptions.path(key),
{ ...input.queryOptions.mcp(key), enabled: mcpEnabled() },
input.queryOptions.lsp(key),
input.queryOptions.providers(key),
],
}))
const pathQuery = useQuery(() => input.queryOptions.path(key))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
const providerQuery = useQuery(() => input.queryOptions.providers(key))
const child = createStore<State>({
project: "",
@@ -204,8 +200,9 @@ export function createChildStoreManager(input: {
},
config: {},
get path() {
if (pathQuery.data) return pathQuery.data
return { state: "", config: "", worktree: "", directory, home: "" }
const EMPTY = { state: "", config: "", worktree: "", directory, home: "" }
if (pathQuery.isLoading) return EMPTY
return pathQuery.data ?? EMPTY
},
status: "loading" as const,
agent: [],
@@ -0,0 +1,7 @@
UPDATE project SET worktree = REPLACE(worktree, char(92), '/') WHERE worktree GLOB '[A-Za-z]:' || char(92) || '*' OR worktree LIKE char(92) || char(92) || '%';
--> statement-breakpoint
UPDATE project SET sandboxes = REPLACE(sandboxes, char(92) || char(92), '/') WHERE instr(sandboxes, char(92)) > 0 AND (worktree GLOB '[A-Za-z]:*' OR worktree LIKE '//%');
--> statement-breakpoint
UPDATE session SET directory = REPLACE(directory, char(92), '/') WHERE directory GLOB '[A-Za-z]:' || char(92) || '*' OR directory LIKE char(92) || char(92) || '%';
--> statement-breakpoint
UPDATE session SET path = REPLACE(path, char(92), '/') WHERE path IS NOT NULL AND instr(path, char(92)) > 0 AND (directory GLOB '[A-Za-z]:*' OR directory LIKE '//%');
File diff suppressed because it is too large Load Diff
@@ -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
+1
View File
@@ -29,6 +29,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
+2 -2
View File
@@ -3,7 +3,7 @@ export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PermissionV2 } from "./permission"
import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider"
import { PositiveInt } from "./schema"
import { State } from "./state"
@@ -26,7 +26,7 @@ export class Info extends Schema.Class<Info>("AgentV2.Info")({
hidden: Schema.Boolean,
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
permissions: PermissionV2.Ruleset,
permissions: PermissionSchema.Ruleset,
}) {
static empty(id: ID) {
return new Info({
+3
View File
@@ -23,5 +23,8 @@ export const migrations = (
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
import("./migration/20260511173437_session-metadata"),
import("./migration/20260601010001_normalize_storage_paths"),
import("./migration/20260601202201_amazing_prowler"),
import("./migration/20260602002951_lowly_union_jack"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,22 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260601010001_normalize_storage_paths",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(
`UPDATE project SET worktree = REPLACE(worktree, char(92), '/') WHERE worktree GLOB '[A-Za-z]:' || char(92) || '*' OR worktree LIKE char(92) || char(92) || '%';`,
)
yield* tx.run(
`UPDATE project SET sandboxes = REPLACE(sandboxes, char(92) || char(92), '/') WHERE instr(sandboxes, char(92)) > 0 AND (worktree GLOB '[A-Za-z]:*' OR worktree LIKE '//%');`,
)
yield* tx.run(
`UPDATE session SET directory = REPLACE(directory, char(92), '/') WHERE directory GLOB '[A-Za-z]:' || char(92) || '*' OR directory LIKE char(92) || char(92) || '%';`,
)
yield* tx.run(
`UPDATE session SET path = REPLACE(path, char(92), '/') WHERE path IS NOT NULL AND instr(path, char(92)) > 0 AND (directory GLOB '[A-Za-z]:*' OR directory LIKE '//%');`,
)
})
},
} 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,24 @@
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
+91
View File
@@ -0,0 +1,91 @@
import nodePath from "path"
import { customType } from "drizzle-orm/sqlite-core"
import { AbsolutePath } from "../schema"
function storagePath(input: string) {
if (process.platform !== "win32") return input
return input.replaceAll("\\", "/")
}
function isWindowsStoragePath(input: string) {
return /^[A-Za-z]:\//.test(input) || input.startsWith("//")
}
function absolute(input: string) {
const result = storagePath(input)
if (!nodePath.posix.isAbsolute(result) && !(process.platform === "win32" && isWindowsStoragePath(result))) {
throw new Error(`Path is not absolute: ${input}`)
}
return result
}
function toPlatform(input: string) {
if (process.platform !== "win32" || !isWindowsStoragePath(input)) return input
return input.replaceAll("/", "\\")
}
export const absoluteColumn = customType<{
data: AbsolutePath
driverData: string
driverOutput: string
}>({
dataType() {
return "text"
},
toDriver(input) {
return absolute(input)
},
fromDriver(input) {
return AbsolutePath.make(toPlatform(absolute(input)))
},
})
// Legacy sessions may persist an empty directory. Keep that existing value
// readable while normalizing and validating every real directory.
export const directoryColumn = customType<{
data: string
driverData: string
driverOutput: string
}>({
dataType() {
return "text"
},
toDriver(input) {
return input ? absolute(input) : input
},
fromDriver(input) {
return input ? toPlatform(absolute(input)) : input
},
})
export const pathColumn = customType<{
data: string
driverData: string
driverOutput: string
}>({
dataType() {
return "text"
},
toDriver(input) {
return storagePath(input)
},
fromDriver(input) {
return storagePath(input)
},
})
export const absoluteArrayColumn = customType<{
data: AbsolutePath[]
driverData: string
driverOutput: string
}>({
dataType() {
return "text"
},
toDriver(input) {
return JSON.stringify(input.map(absolute))
},
fromDriver(input) {
return (JSON.parse(input) as string[]).map((item) => AbsolutePath.make(toPlatform(absolute(item))))
},
})
+8
View File
@@ -13,6 +13,10 @@ import { Npm } from "./npm"
import { ModelsDev } from "./models-dev"
import { AppFileSystem } from "./filesystem"
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", {
lookup: (ref: Location.Ref) => {
@@ -25,6 +29,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Catalog.locationLayer,
AgentV2.locationLayer,
PluginBoot.locationLayer,
PermissionV2.locationLayer,
).pipe(Layer.provideMerge(location), Layer.fresh)
},
idleTimeToLive: "60 minutes",
@@ -36,5 +41,8 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
ModelsDev.defaultLayer,
AppFileSystem.defaultLayer,
Global.defaultLayer,
Database.defaultLayer,
SessionV2.defaultLayer,
PermissionSaved.defaultLayer,
],
}) {}
+285 -34
View File
@@ -1,42 +1,110 @@
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 { Identifier } from "./id/id"
import { Newtype } from "./schema"
import { PermissionSchema } from "./permission/schema"
import { PermissionSaved } from "./permission/saved"
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))
}
export { Effect, Rule, Ruleset } from "./permission/schema"
type Effect = PermissionSchema.Effect
type Rule = PermissionSchema.Rule
type Ruleset = PermissionSchema.Ruleset
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 type Action = typeof Action.Type
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "Permission.Rule" })
export type Rule = typeof Rule.Type
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
feedback: Schema.String,
}) {}
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "Permission.Ruleset" })
export type Ruleset = typeof Ruleset.Type
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
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 (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
.findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? {
action,
resource: "*",
effect: "ask",
}
)
}
@@ -45,12 +113,195 @@ export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return new Set(
tools.filter((tool) => {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
)
export interface Interface {
readonly ask: (input: AssertInput) => EffectRuntime.Effect<AskResult, SessionV2.NotFoundError>
readonly assert: (input: AssertInput) => EffectRuntime.Effect<void, Error | SessionV2.NotFoundError>
readonly reply: (input: ReplyInput) => EffectRuntime.Effect<void, NotFoundError>
readonly get: (id: ID) => EffectRuntime.Effect<Request | undefined>
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
+87
View File
@@ -0,0 +1,87 @@
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 -36
View File
@@ -104,23 +104,23 @@ export const Plugin = PluginV2.define({
const worktree = location.directory
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ permission: "external_directory", pattern: "*", action: "ask" },
{ action: "external_directory", resource: "*", effect: "ask" },
...whitelistedDirs.map(
(pattern): PermissionV2.Rule => ({ permission: "external_directory", pattern, action: "allow" }),
(resource): PermissionV2.Rule => ({ action: "external_directory", resource, effect: "allow" }),
),
]
const defaults: PermissionV2.Ruleset = [
{ permission: "*", pattern: "*", action: "allow" },
{ action: "*", resource: "*", effect: "allow" },
...readonlyExternalDirectory,
{ permission: "question", pattern: "*", action: "deny" },
{ permission: "plan_enter", pattern: "*", action: "deny" },
{ permission: "plan_exit", pattern: "*", action: "deny" },
{ permission: "repo_clone", pattern: "*", action: "deny" },
{ permission: "repo_overview", pattern: "*", action: "deny" },
{ permission: "read", pattern: "*", action: "allow" },
{ permission: "read", pattern: "*.env", action: "ask" },
{ permission: "read", pattern: "*.env.*", action: "ask" },
{ permission: "read", pattern: "*.env.example", action: "allow" },
{ action: "question", resource: "*", effect: "deny" },
{ action: "plan_enter", resource: "*", effect: "deny" },
{ action: "plan_exit", resource: "*", effect: "deny" },
{ action: "repo_clone", resource: "*", effect: "deny" },
{ action: "repo_overview", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*.env", effect: "ask" },
{ action: "read", resource: "*.env.*", effect: "ask" },
{ action: "read", resource: "*.env.example", effect: "allow" },
]
yield* agent.update((editor) => {
@@ -129,8 +129,8 @@ export const Plugin = PluginV2.define({
item.mode = "primary"
item.permissions.push(
...PermissionV2.merge(defaults, [
{ permission: "question", pattern: "*", action: "allow" },
{ permission: "plan_enter", pattern: "*", action: "allow" },
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_enter", resource: "*", effect: "allow" },
]),
)
})
@@ -140,15 +140,15 @@ export const Plugin = PluginV2.define({
item.mode = "primary"
item.permissions.push(
...PermissionV2.merge(defaults, [
{ permission: "question", pattern: "*", action: "allow" },
{ permission: "plan_exit", pattern: "*", action: "allow" },
{ permission: "external_directory", pattern: path.join(Global.Path.data, "plans", "*"), action: "allow" },
{ permission: "edit", pattern: "*", action: "deny" },
{ permission: "edit", pattern: path.join(".opencode", "plans", "*.md"), action: "allow" },
{ action: "question", resource: "*", effect: "allow" },
{ action: "plan_exit", resource: "*", effect: "allow" },
{ action: "external_directory", resource: path.join(Global.Path.data, "plans", "*"), effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "edit", resource: path.join(".opencode", "plans", "*.md"), effect: "allow" },
{
permission: "edit",
pattern: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
action: "allow",
action: "edit",
resource: path.relative(worktree, path.join(Global.Path.data, "plans", "*.md")),
effect: "allow",
},
]),
)
@@ -158,9 +158,7 @@ export const Plugin = PluginV2.define({
item.description =
"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.permissions.push(
...PermissionV2.merge(defaults, [{ permission: "todowrite", pattern: "*", action: "deny" }]),
)
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "todowrite", resource: "*", effect: "deny" }]))
})
editor.update(AgentV2.ID.make("explore"), (item) => {
@@ -172,14 +170,14 @@ export const Plugin = PluginV2.define({
...PermissionV2.merge(
defaults,
[
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "grep", pattern: "*", action: "allow" },
{ permission: "glob", pattern: "*", action: "allow" },
{ permission: "list", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "webfetch", pattern: "*", action: "allow" },
{ permission: "websearch", pattern: "*", action: "allow" },
{ permission: "read", pattern: "*", action: "allow" },
{ action: "*", resource: "*", effect: "deny" },
{ action: "grep", resource: "*", effect: "allow" },
{ action: "glob", resource: "*", effect: "allow" },
{ action: "list", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "allow" },
{ action: "webfetch", resource: "*", effect: "allow" },
{ action: "websearch", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "allow" },
],
readonlyExternalDirectory,
),
@@ -190,21 +188,21 @@ export const Plugin = PluginV2.define({
item.mode = "primary"
item.hidden = true
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) => {
item.mode = "primary"
item.hidden = true
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) => {
item.mode = "primary"
item.hidden = true
item.system = PROMPT_SUMMARY
item.permissions.push(...PermissionV2.merge(defaults, [{ permission: "*", pattern: "*", action: "deny" }]))
item.permissions.push(...PermissionV2.merge(defaults, [{ action: "*", resource: "*", effect: "deny" }]))
})
})
}),
+3 -2
View File
@@ -1,10 +1,11 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import * as DatabasePath from "../database/path"
import { Timestamps } from "../database/schema.sql"
import { ProjectV2 } from "../project"
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectV2.ID>().primaryKey(),
worktree: text().notNull(),
worktree: DatabasePath.absoluteColumn().notNull(),
vcs: text(),
name: text(),
icon_url: text(),
@@ -12,6 +13,6 @@ export const ProjectTable = sqliteTable("project", {
icon_color: text(),
...Timestamps,
time_initialized: integer(),
sandboxes: text({ mode: "json" }).notNull().$type<string[]>(),
sandboxes: DatabasePath.absoluteArrayColumn().notNull(),
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})
+8 -4
View File
@@ -16,6 +16,7 @@ import { SessionProjector } from "./session/projector"
import { SessionMessageTable, SessionTable } from "./session/sql"
import { SessionSchema } from "./session/schema"
import { AbsolutePath, RelativePath } from "./schema"
import { AgentV2 } from "./agent"
// get project -> project.locations
//
@@ -141,14 +142,12 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
return new SessionSchema.Info({
return SessionSchema.Info.make({
id: SessionSchema.ID.make(row.id),
projectID: ProjectV2.ID.make(row.project_id),
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
title: row.title,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
path: row.path ?? "",
agent: row.agent ?? undefined,
agent: row.agent ? AgentV2.ID.make(row.agent) : undefined,
model: row.model
? {
id: ModelV2.ID.make(row.model.id),
@@ -166,6 +165,11 @@ function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
write: row.tokens_cache_write,
},
},
location: Location.Ref.make({
directory: AbsolutePath.make(row.directory),
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
+2 -2
View File
@@ -2,7 +2,7 @@ export * as SessionLegacy from "./legacy"
import { Effect, Schema, Types } from "effect"
import { EventV2 } from "../event"
import { PermissionV2 } from "../permission"
import { PermissionLegacy } from "../permission/legacy"
import { ProjectV2 } from "../project"
import { ProviderV2 } from "../provider"
import { optionalOmitUndefined, withStatics } from "../schema"
@@ -558,7 +558,7 @@ export const SessionInfo = Schema.Struct({
compacting: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(Schema.Finite),
}),
permission: optionalOmitUndefined(PermissionV2.Ruleset),
permission: optionalOmitUndefined(PermissionLegacy.Ruleset),
revert: optionalOmitUndefined(SessionRevert),
}).annotate({ identifier: "Session" })
export type SessionInfo = typeof SessionInfo.Type
+8 -16
View File
@@ -5,9 +5,9 @@ import { Location } from "../location"
import { ModelV2 } from "../model"
import { ProjectV2 } from "../project"
import { RelativePath, optionalOmitUndefined, withStatics } from "../schema"
import { WorkspaceV2 } from "../workspace"
import { Identifier } from "../util/identifier"
import { V2Schema } from "../v2-schema"
import { AgentV2 } from "../agent"
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
identifier: "Session.Delivery",
@@ -24,22 +24,12 @@ export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
)
export type ID = typeof ID.Type
export const LegacyInfo = Schema.Struct({
export class Info extends Schema.Class<Info>("SessionV2.Info")({
id: ID,
location: Location.Ref,
subpath: RelativePath, // derived from location
project: ProjectV2.ID, // derived from location
})
export type LegacyInfo = typeof LegacyInfo.Type
export class Info extends Schema.Class<Info>("Session.Info")({
id: ID,
parentID: optionalOmitUndefined(ID),
parentID: ID.pipe(optionalOmitUndefined),
projectID: ProjectV2.ID,
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String),
model: ModelV2.Ref.pipe(optionalOmitUndefined),
agent: AgentV2.ID.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
cost: Schema.Finite,
tokens: Schema.Struct({
input: Schema.Finite,
@@ -53,7 +43,9 @@ export class Info extends Schema.Class<Info>("Session.Info")({
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis,
archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis),
archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
}),
title: Schema.String,
location: Location.Ref,
subpath: RelativePath.pipe(Schema.optional),
}) {}
+5 -12
View File
@@ -1,8 +1,9 @@
import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core"
import * as DatabasePath from "../database/path"
import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message"
import type { Snapshot } from "../snapshot"
import { PermissionV2 } from "../permission"
import { PermissionLegacy } from "../permission/legacy"
import { ProjectV2 } from "../project"
import type { SessionSchema } from "./schema"
import type { MessageID, PartID, Info as LegacyMessageInfo, Part as LegacyMessagePart } from "./legacy"
@@ -24,8 +25,8 @@ export const SessionTable = sqliteTable(
workspace_id: text().$type<WorkspaceV2.ID>(),
parent_id: text().$type<SessionSchema.ID>(),
slug: text().notNull(),
directory: text().notNull(),
path: text(),
directory: DatabasePath.directoryColumn().notNull(),
path: DatabasePath.pathColumn(),
title: text().notNull(),
version: text().notNull(),
share_url: text(),
@@ -41,7 +42,7 @@ export const SessionTable = sqliteTable(
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
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(),
model: text({ mode: "json" }).$type<{
id: string
@@ -128,11 +129,3 @@ export const SessionMessageTable = sqliteTable(
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) =>
editor.update(build, (agent) => {
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({
source: { type: "memory" },
info: decode({
permissions: [{ permission: "bash", pattern: "*", action: "ask" }],
permissions: [{ action: "bash", resource: "*", effect: "ask" }],
agents: {
build: {
permissions: [{ permission: "bash", pattern: "git *", action: "allow" }],
permissions: [{ action: "bash", resource: "git *", effect: "allow" }],
},
reviewer: {
model: "openrouter/openai/gpt-5",
description: "Review changes",
mode: "subagent",
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
},
removed: { description: "Removed later" },
},
@@ -65,12 +65,12 @@ describe("ConfigAgentPlugin.Plugin", () => {
const buildAgent = yield* agents.get(build)
if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git *", action: "allow" },
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "bash", resource: "git *", effect: "allow" },
])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).action).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).action).toBe("ask")
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
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" },
})
expect(reviewer.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "*", action: "deny" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "edit", resource: "*", effect: "deny" },
])
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" },
username: "test-user",
permissions: [
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git status", action: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "bash", resource: "git status", effect: "allow" },
],
agents: {
reviewer: {
@@ -188,7 +188,7 @@ describe("Config", () => {
color: "warning",
steps: 12,
disabled: false,
permissions: [{ permission: "edit", pattern: "*", action: "deny" }],
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
},
},
snapshots: false,
@@ -254,8 +254,8 @@ describe("Config", () => {
expect(documents[0]?.info.enterprise).toEqual({ url: "https://share.example.com" })
expect(documents[0]?.info.username).toBe("test-user")
expect(documents[0]?.info.permissions).toEqual([
{ permission: "bash", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "git status", action: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "bash", resource: "git status", effect: "allow" },
])
expect(documents[0]?.info.agents?.reviewer).toEqual({
model: "openrouter/openai/gpt-5",
@@ -271,7 +271,7 @@ describe("Config", () => {
color: "warning",
steps: 12,
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.watcher).toEqual({ ignore: ["node_modules/**", "dist/**", ".git"] })
+169 -2
View File
@@ -4,9 +4,15 @@ import { fileURLToPath } from "url"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Effect } from "effect"
import { sql } from "drizzle-orm"
import { eq, inArray, sql } from "drizzle-orm"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
@@ -37,7 +43,7 @@ describe("DatabaseMigration", () => {
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
name: "session",
})
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 21 })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 24 })
}),
)
})
@@ -71,6 +77,167 @@ describe("DatabaseMigration", () => {
)
})
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`)
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`)
// Windows-shaped rows (drive + backslash) must be normalized.
yield* db.run(
sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([
"C:\\Repo\\Thing\\sandbox",
])})`,
)
yield* db.run(
sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`,
)
// UNC worktrees and their sandboxes must normalize too (not just drive paths).
yield* db.run(
sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([
"\\\\server\\share\\sandbox",
])})`,
)
// The "/" worktree sentinel and POSIX paths (including a pathological
// backslash in a POSIX filename) must survive byte-for-byte.
yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`)
yield* db.run(
sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`,
)
yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration])
expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({
worktree: "C:/Repo/Thing",
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
})
expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({
directory: "C:/Repo/Thing/packages/api",
path: "packages/api",
})
expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({
worktree: "//server/share",
sandboxes: JSON.stringify(["//server/share/sandbox"]),
})
expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" })
expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({
directory: "/home/me/we\\ird",
path: "src\\weird",
})
}),
)
})
test("maps native Windows paths through database columns", async () => {
if (process.platform !== "win32") return
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
const projectID = ProjectV2.ID.make("codec_project")
const worktree = AbsolutePath.make("C:\\Repo\\Thing")
const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox")
const directory = "C:\\Repo\\Thing\\packages\\api"
const sessionID = SessionSchema.ID.make("ses_codec")
expect(() =>
Effect.runSync(
db
.insert(ProjectTable)
.values({
id: ProjectV2.ID.make("invalid_path"),
worktree: AbsolutePath.make("not-absolute"),
sandboxes: [],
time_created: 1,
time_updated: 1,
})
.run(),
),
).toThrow()
yield* db
.insert(ProjectTable)
.values({
id: projectID,
worktree,
sandboxes: [sandbox],
time_created: 1,
time_updated: 1,
})
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "codec",
directory,
path: "packages\\api",
title: "Codec",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
expect(
yield* db.get<{ worktree: string; sandboxes: string }>(
sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`,
),
).toEqual({
worktree: "C:/Repo/Thing",
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
})
expect(
yield* db.get<{ directory: string; path: string }>(
sql`SELECT directory, path FROM session WHERE id = ${sessionID}`,
),
).toEqual({
directory: "C:/Repo/Thing/packages/api",
path: "packages/api",
})
const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get()
const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get()
expect(project?.worktree).toBe(worktree)
expect(project?.sandboxes).toEqual([sandbox])
expect(session?.directory).toBe(directory)
expect(session?.path).toBe("packages/api")
expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe(
sessionID,
)
const moved = AbsolutePath.make("D:\\Moved\\Thing")
const updated = yield* db
.update(ProjectTable)
.set({ worktree: moved, sandboxes: [moved] })
.where(eq(ProjectTable.id, projectID))
.returning()
.get()
expect(updated?.worktree).toBe(moved)
expect(updated?.sandboxes).toEqual([moved])
expect(
yield* db.get<{ worktree: string; sandboxes: string }>(
sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`,
),
).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) })
expect(
(yield* db
.select()
.from(ProjectTable)
.where(inArray(ProjectTable.worktree, [moved]))
.get())?.id,
).toBe(projectID)
yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`)
expect(() =>
Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()),
).toThrow()
}),
)
})
test("imports existing drizzle migration state", async () => {
await run(
Effect.gen(function* () {
+174
View File
@@ -0,0 +1,174 @@
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 { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Provider } from "@/provider/provider"
@@ -36,7 +37,7 @@ export const Info = Schema.Struct({
topP: Schema.optional(Schema.Finite),
temperature: Schema.optional(Schema.Finite),
color: Schema.optional(Schema.String),
permission: Permission.Ruleset,
permission: PermissionLegacy.Ruleset,
model: Schema.optional(
Schema.Struct({
modelID: ProviderV2.ModelID,
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import type { Permission } from "../permission"
import type { Agent } from "./agent"
@@ -15,10 +16,10 @@ import type { Agent } from "./agent"
* doesn't already permit them.
*/
export function deriveSubagentSessionPermission(input: {
parentSessionPermission: Permission.Ruleset
parentSessionPermission: PermissionLegacy.Ruleset
parentAgent: Agent.Info | undefined
subagent: Agent.Info
}): Permission.Ruleset {
}): PermissionLegacy.Ruleset {
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
const parentAgentDenies =
+3 -2
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { EOL } from "os"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { basename } from "path"
@@ -193,12 +194,12 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
ask(req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) {
return Effect.sync(() => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset })
throw new PermissionLegacy.DeniedError({ ruleset })
}
}
})
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
// CLI entry point for `opencode run`.
//
// Handles three modes:
@@ -367,7 +368,7 @@ export const RunCommand = effectCmd({
process.exit(1)
}
const rules: Permission.Ruleset = args.interactive
const rules: PermissionLegacy.Ruleset = args.interactive
? []
: [
{
@@ -113,6 +113,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const kv = useKV()
const fullSyncedSessions = new Set<string>()
const syncingSessions = new Map<string, Promise<void>>()
const hydratingSessions = new Map<string, { messages: Set<string>; parts: Set<string> }>()
const touchMessage = (sessionID: string, messageID: string) => {
hydratingSessions.get(sessionID)?.messages.add(messageID)
}
const touchPart = (sessionID: string, partID: string) => {
hydratingSessions.get(sessionID)?.parts.add(partID)
}
function sessionListQuery(): { scope?: "project"; path?: string } {
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
@@ -251,6 +259,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
case "message.updated": {
touchMessage(event.properties.info.sessionID, event.properties.info.id)
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
@@ -290,6 +299,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "message.removed": {
touchMessage(event.properties.sessionID, event.properties.messageID)
const messages = store.message[event.properties.sessionID]
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
if (result.found) {
@@ -304,6 +314,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
case "message.part.updated": {
touchPart(event.properties.part.sessionID, event.properties.part.id)
const parts = store.part[event.properties.part.messageID]
if (!parts) {
setStore("part", event.properties.part.messageID, [event.properties.part])
@@ -329,6 +340,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (!parts) break
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
if (!result.found) break
touchPart(event.properties.sessionID, event.properties.partID)
setStore(
"part",
event.properties.messageID,
@@ -343,6 +355,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
case "message.part.removed": {
touchPart(event.properties.sessionID, event.properties.partID)
const parts = store.part[event.properties.messageID]
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
if (result.found) {
@@ -520,28 +533,76 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
])
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
const infos: (typeof draft.message)[string] = []
for (const message of messages.data ?? []) {
infos.push(message.info)
draft.part[message.info.id] = message.parts
}
draft.message[sessionID] = infos
draft.session_diff[sessionID] = diff.data ?? []
}),
)
fullSyncedSessions.add(sessionID)
const syncing = syncingSessions.get(sessionID)
if (syncing) return syncing
const tracker = { messages: new Set<string>(), parts: new Set<string>() }
hydratingSessions.set(sessionID, tracker)
const task = (async () => {
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
])
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
const currentMessages = draft.message[sessionID] ?? []
const infos = (messages.data ?? []).flatMap((message) => {
if (!tracker.messages.has(message.info.id)) return [message.info]
const current = currentMessages.find((item) => item.id === message.info.id)
return current ? [current] : []
})
infos.push(
...currentMessages.filter(
(message) => tracker.messages.has(message.id) && !infos.some((item) => item.id === message.id),
),
)
const removed = infos.slice(0, -100)
const visible = infos.slice(-100)
const visibleIDs = new Set(visible.map((message) => message.id))
for (const message of messages.data ?? []) {
if (!visibleIDs.has(message.info.id)) {
delete draft.part[message.info.id]
continue
}
const currentParts = draft.part[message.info.id] ?? []
const parts = message.parts.flatMap((part) => {
const current = currentParts.find((item) => item.id === part.id)
if (tracker.parts.has(part.id)) return current ? [current] : []
if (
current &&
(part.type === "text" || part.type === "reasoning") &&
(current.type === "text" || current.type === "reasoning") &&
part.text.length === 0 &&
current.text.length > 0
) {
return [current]
}
return [part]
})
parts.push(
...currentParts.filter(
(part) => tracker.parts.has(part.id) && !parts.some((item) => item.id === part.id),
),
)
draft.part[message.info.id] = parts
}
for (const message of removed) delete draft.part[message.id]
draft.message[sessionID] = visible
draft.session_diff[sessionID] = diff.data ?? []
}),
)
fullSyncedSessions.add(sessionID)
})().finally(() => {
syncingSessions.delete(sessionID)
hydratingSessions.delete(sessionID)
})
syncingSessions.set(sessionID, task)
return task
},
},
bootstrap,
@@ -1,106 +0,0 @@
{
"name": "subagent-lifecycle",
"frames": {
"running": {
"prompt": "Preview a foreground subagent while it is reading.",
"parts": [
{ "type": "text", "text": "Running delegated work:" },
{
"type": "subagent",
"agent": "explore",
"description": "Inspect renderer",
"state": "running",
"childTools": [
{
"tool": "read",
"title": "src/cli/cmd/tui/routes/session/index.tsx",
"input": { "filePath": "src/cli/cmd/tui/routes/session/index.tsx" }
}
]
}
]
},
"active-background": {
"prompt": "Preview a launched background subagent whose child is still reading.",
"parts": [
{ "type": "text", "text": "Active background work:" },
{
"type": "subagent",
"agent": "explore",
"description": "Inspect renderer",
"state": "active-background",
"childTools": [
{
"tool": "read",
"title": "src/cli/cmd/tui/routes/session/index.tsx",
"input": { "filePath": "src/cli/cmd/tui/routes/session/index.tsx" }
}
]
}
]
},
"retrying": {
"prompt": "Preview a background subagent retrying an upstream request.",
"parts": [
{
"type": "subagent",
"agent": "explore",
"description": "Retry provider request while maintaining context",
"state": "retrying",
"background": true,
"message": "Rate limited by provider; retrying after quota window",
"attempt": 2
}
]
},
"failed": {
"prompt": "Preview a delegated task failure.",
"parts": [
{
"type": "subagent",
"agent": "general",
"description": "Fail delegated work after checking upstream status",
"state": "error",
"error": "Provider returned an authentication error"
}
]
},
"completed": {
"prompt": "Preview completed foreground and background subagents.",
"parts": [
{ "type": "text", "text": "Completed delegated work:" },
{
"type": "subagent",
"agent": "general",
"description": "Answer directly",
"state": "completed",
"durationMs": 501
},
{
"type": "subagent",
"agent": "general",
"description": "Answer directly",
"background": true,
"state": "completed",
"durationMs": 501
},
{
"type": "subagent",
"agent": "explore",
"description": "Inspect renderer",
"background": true,
"state": "completed",
"durationMs": 501,
"childTools": [
{
"tool": "read",
"title": "src/cli/cmd/tui/routes/session/index.tsx",
"state": "completed",
"input": { "filePath": "src/cli/cmd/tui/routes/session/index.tsx" }
}
]
}
]
}
}
}
@@ -1,361 +0,0 @@
import type {
AssistantMessage,
Message,
Part,
Session,
SessionStatus,
TextPart,
ToolPart,
ToolState,
UserMessage,
} from "@opencode-ai/sdk/v2"
import { Schema } from "effect"
import type { EventSource } from "../context/sdk"
const Data = Schema.Record(Schema.String, Schema.Unknown)
const ChildTool = Schema.Struct({
tool: Schema.String,
title: Schema.String,
state: Schema.optional(Schema.Literals(["running", "completed", "error"])),
input: Schema.optional(Data),
metadata: Schema.optional(Data),
})
const SubagentFields = {
type: Schema.Literal("subagent"),
agent: Schema.String,
description: Schema.String,
durationMs: Schema.optional(Schema.Number),
childTools: Schema.optional(Schema.Array(ChildTool)),
}
const PartInput = Schema.Union([
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
Schema.Struct({
type: Schema.Literal("tool"),
tool: Schema.String,
title: Schema.String,
state: Schema.optional(Schema.Literals(["running", "completed", "error"])),
input: Schema.optional(Data),
metadata: Schema.optional(Data),
}),
Schema.Struct({ ...SubagentFields, state: Schema.Literal("running") }),
Schema.Struct({ ...SubagentFields, state: Schema.Literal("active-background") }),
Schema.Struct({ ...SubagentFields, state: Schema.Literal("completed"), background: Schema.optional(Schema.Boolean) }),
Schema.Struct({
...SubagentFields,
state: Schema.Literal("retrying"),
background: Schema.optional(Schema.Boolean),
message: Schema.String,
attempt: Schema.Number,
}),
Schema.Struct({
...SubagentFields,
state: Schema.Literal("error"),
background: Schema.optional(Schema.Boolean),
error: Schema.String,
}),
])
const Frame = Schema.Struct({
prompt: Schema.String,
parts: Schema.Array(PartInput),
})
const Fixture = Schema.Struct({
name: Schema.String,
frames: Schema.Record(Schema.String, Frame),
})
const decodeFixture = Schema.decodeUnknownSync(Schema.fromJsonString(Fixture))
type Transcript = Array<{ info: Message; parts: Part[] }>
type FrameInput = typeof Frame.Type
export async function createDebugFrameTransport(input: { file: string; frame: string; directory: string }) {
const fixture = decodeFixture(await Bun.file(input.file).text())
const frame = fixture.frames[input.frame]
if (!frame) {
throw new Error(
`Unknown debug frame "${input.frame}" in ${input.file}. Available frames: ${Object.keys(fixture.frames).join(", ")}`,
)
}
const sessionID = `ses_debug_${fixture.name.replaceAll(/[^a-zA-Z0-9_]/g, "_")}_${input.frame.replaceAll(/[^a-zA-Z0-9_]/g, "_")}`
const created = 1_000_000
const root = makeSession(sessionID, input.directory, `${fixture.name}: ${input.frame}`, created)
const childSessions = new Map<string, { session: Session; transcript: Transcript }>()
const statuses: Record<string, SessionStatus> = {}
const parts = compileParts(frame, sessionID, created, input.directory, childSessions, statuses)
const transcript = turn(sessionID, created, frame.prompt, parts)
const fetch = createFetch({ root, transcript, childSessions, statuses, directory: input.directory })
const events: EventSource = { subscribe: async () => () => {} }
return { sessionID, fetch, events }
}
function compileParts(
frame: FrameInput,
sessionID: string,
created: number,
directory: string,
children: Map<string, { session: Session; transcript: Transcript }>,
statuses: Record<string, SessionStatus>,
) {
return frame.parts.map((part, index): Part => {
const id = `part_${index.toString().padStart(2, "0")}`
if (part.type === "text") return text(sessionID, assistantID(sessionID), id, part.text)
if (part.type === "tool") {
return tool(
sessionID,
assistantID(sessionID),
id,
part.tool,
toolState(part.state ?? "completed", part.input ?? {}, part.metadata ?? {}, part.title, created),
)
}
const childID = `${sessionID}_child_${index}`
const childTools = part.childTools ?? []
const complete = part.state === "completed"
children.set(childID, {
session: { ...makeSession(childID, directory, part.description, created), parentID: sessionID },
transcript: turn(
childID,
created,
part.description,
childTools.map((child, childIndex) =>
tool(
childID,
assistantID(childID),
`part_child_${childIndex}`,
child.tool,
toolState(
child.state ?? (complete ? "completed" : "running"),
child.input ?? {},
child.metadata ?? {},
child.title,
created,
),
),
),
complete,
part.durationMs ?? 501,
),
})
if (part.state === "active-background") statuses[childID] = { type: "busy" }
if (part.state === "retrying") {
statuses[childID] = { type: "retry", attempt: part.attempt, message: part.message, next: created + 1000 }
}
const state = part.state === "completed" ? "completed" : part.state === "error" ? "error" : "running"
const background =
part.state === "active-background" ||
((part.state === "completed" || part.state === "retrying" || part.state === "error") && part.background === true)
return tool(
sessionID,
assistantID(sessionID),
id,
"task",
toolState(
state,
{
description: part.description,
subagent_type: part.agent,
},
{
sessionId: childID,
...(background ? { background: true } : {}),
},
part.state === "error" ? part.error : part.description,
created,
),
)
})
}
function createFetch(input: {
root: Session
transcript: Transcript
childSessions: Map<string, { session: Session; transcript: Transcript }>
statuses: Record<string, SessionStatus>
directory: string
}) {
const provider = {
id: "debug-frame",
name: "Debug Frame",
source: "custom",
env: [],
options: {},
models: {
preview: {
id: "preview",
providerID: "debug-frame",
api: { id: "preview", url: "", npm: "" },
name: "Preview",
capabilities: {
temperature: false,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 1, output: 1 },
status: "active",
options: {},
headers: {},
release_date: "",
},
},
}
return Object.assign(
async (resource: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(resource, init)
const pathname = new URL(request.url).pathname
if (request.method !== "GET") throw new Error(`Unexpected debug frame request: ${request.method} ${pathname}`)
const child = input.childSessions.get(pathname.split("/")[2] ?? "")
if (request.method === "GET" && pathname === `/session/${input.root.id}`) return json(input.root)
if (request.method === "GET" && pathname === `/session/${input.root.id}/message`) return json(input.transcript)
if (request.method === "GET" && child && pathname === `/session/${child.session.id}`) return json(child.session)
if (request.method === "GET" && child && pathname === `/session/${child.session.id}/message`)
return json(child.transcript)
if (request.method === "GET" && (pathname.endsWith("/todo") || pathname.endsWith("/diff"))) return json([])
switch (pathname) {
case "/agent":
return json([
{
name: "build",
description: "Internal debug frame",
mode: "primary",
native: true,
permission: [],
model: { providerID: "debug-frame", modelID: "preview" },
options: {},
},
])
case "/config/providers":
return json({ providers: [provider], default: { "debug-frame": "preview" } })
case "/provider":
return json({ all: [], default: { "debug-frame": "preview" }, connected: ["debug-frame"] })
case "/session":
return json([input.root])
case "/session/status":
return json(input.statuses)
case "/path":
return json({ home: "", state: "", config: "", worktree: input.directory, directory: input.directory })
case "/project/current":
return json({ id: "debug-frame" })
case "/vcs":
return json({ branch: "debug-frame" })
case "/command":
case "/experimental/workspace":
case "/experimental/workspace/status":
case "/formatter":
case "/lsp":
return json([])
case "/config":
case "/experimental/resource":
case "/mcp":
case "/provider/auth":
return json({})
case "/experimental/console":
return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
}
throw new Error(`Unexpected debug frame request: ${request.method} ${pathname}`)
},
{ preconnect: fetch.preconnect },
)
}
function makeSession(id: string, directory: string, title: string, created: number): Session {
return {
id,
slug: id,
projectID: "global",
directory,
title,
version: "debug-frame",
time: { created, updated: created },
}
}
function turn(
sessionID: string,
created: number,
prompt: string,
parts: Part[],
complete = true,
durationMs = 501,
): Transcript {
return [
{ info: userMessage(sessionID, created), parts: [text(sessionID, userID(sessionID), "part_user", prompt)] },
{ info: assistantMessage(sessionID, created + 1, complete ? created + durationMs : undefined), parts },
]
}
function userMessage(sessionID: string, created: number): UserMessage {
return {
id: userID(sessionID),
sessionID,
role: "user",
time: { created },
agent: "build",
model: { providerID: "debug-frame", modelID: "preview" },
}
}
function assistantMessage(sessionID: string, created: number, completed?: number): AssistantMessage {
return {
id: assistantID(sessionID),
sessionID,
role: "assistant",
time: { created, ...(completed === undefined ? {} : { completed }) },
parentID: userID(sessionID),
modelID: "preview",
providerID: "debug-frame",
mode: "build",
agent: "build",
path: { cwd: process.cwd(), root: process.cwd() },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
...(completed === undefined ? {} : { finish: "stop" }),
}
}
function userID(sessionID: string) {
return `msg_${sessionID}_user`
}
function assistantID(sessionID: string) {
return `msg_${sessionID}_assistant`
}
function text(sessionID: string, messageID: string, id: string, value: string): TextPart {
return { id, sessionID, messageID, type: "text", text: value }
}
function tool(sessionID: string, messageID: string, id: string, name: string, state: ToolState): ToolPart {
return { id, sessionID, messageID, type: "tool", callID: `call_${id}`, tool: name, state }
}
function toolState(
state: "running" | "completed" | "error",
input: Record<string, unknown>,
metadata: Record<string, unknown>,
title: string,
created: number,
): ToolState {
if (state === "running") return { status: "running", input, metadata, title, time: { start: created } }
if (state === "error") {
return { status: "error", input, metadata, error: title, time: { start: created, end: created + 1 } }
}
return {
status: "completed",
input,
metadata,
output: title,
title,
time: { start: created, end: created + 1 },
}
}
function json(value: unknown) {
return new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } })
}
+52 -118
View File
@@ -21,7 +21,6 @@ import {
sanitizedProcessEnv,
} from "@opencode-ai/core/util/opencode-process"
import { validateSession } from "./validate-session"
import { Flag } from "@opencode-ai/core/flag/flag"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -112,14 +111,6 @@ export const TuiThreadCommand = cmd({
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("debug-scenario", {
type: "string",
describe: "load an internal TUI debug scenario fixture",
})
.option("debug-frame", {
type: "string",
describe: "select a named frame from an internal TUI debug scenario",
}),
handler: async (args) => {
// Keep ENABLE_PROCESSED_INPUT cleared even if other code flips it.
@@ -136,40 +127,10 @@ export const TuiThreadCommand = cmd({
return
}
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const debug = Boolean(args.debugScenario || args.debugFrame)
if (debug && (!args.debugScenario || !args.debugFrame)) {
UI.error("--debug-scenario and --debug-frame must be used together")
process.exitCode = 1
return
}
if (debug && !Flag.OPENCODE_PURE) {
UI.error("debug scenarios require --pure")
process.exitCode = 1
return
}
if (
debug &&
(external || args.continue || args.session || args.fork || args.prompt || args.model || args.agent)
) {
UI.error("debug scenarios cannot be combined with network, session, fork, prompt, model, or agent options")
process.exitCode = 1
return
}
// Resolve relative --project paths from PWD, then use the real cwd after
// chdir so the thread and worker share the same directory key.
const next = resolveThreadDirectory(args.project)
const file = await target()
try {
process.chdir(next)
} catch {
@@ -177,73 +138,6 @@ export const TuiThreadCommand = cmd({
return
}
const cwd = Filesystem.resolve(process.cwd())
const launch = async (input: {
url: string
sessionID?: string
fetch?: typeof fetch
events?: EventSource
prompt?: string
onSnapshot?: () => Promise<string[]>
stop?: () => Promise<void>
}) => {
try {
try {
await validateSession({
url: input.url,
sessionID: input.sessionID,
directory: cwd,
fetch: input.fetch,
})
} catch (error) {
UI.error(errorMessage(error))
process.exitCode = 1
return
}
const config = await TuiConfig.get()
const { createTuiRenderer, tui } = await import("./app")
const renderer = await createTuiRenderer(config)
await tui({
url: input.url,
renderer,
onSnapshot: input.onSnapshot,
config,
directory: cwd,
fetch: input.fetch,
events: input.events,
args: {
continue: args.continue,
sessionID: input.sessionID,
agent: args.agent,
model: args.model,
prompt: input.prompt,
fork: args.fork,
},
}).done
} finally {
await input.stop?.()
}
}
if (args.debugScenario && args.debugFrame) {
const { createDebugFrameTransport } = await import("./debug/frame")
const transport = await createDebugFrameTransport({
file: Filesystem.resolveFilePath(cwd, args.debugScenario),
frame: args.debugFrame,
directory: cwd,
})
await launch({
url: "http://opencode.debug",
sessionID: transport.sessionID,
fetch: transport.fetch,
events: transport.events,
})
process.exit(0)
return
}
const file = await target()
const env = sanitizedProcessEnv({
[OPENCODE_PROCESS_ROLE]: "worker",
[OPENCODE_RUN_ID]: ensureRunID(),
@@ -293,6 +187,16 @@ export const TuiThreadCommand = cmd({
}
const prompt = await input(args.prompt)
const config = await TuiConfig.get()
const network = resolveNetworkOptionsNoConfig(args)
const external =
process.argv.includes("--port") ||
process.argv.includes("--hostname") ||
process.argv.includes("--mdns") ||
network.mdns ||
network.port !== 0 ||
network.hostname !== "127.0.0.1"
const transport = external
? {
@@ -306,21 +210,51 @@ export const TuiThreadCommand = cmd({
events: createEventSource(client),
}
try {
await validateSession({
url: transport.url,
sessionID: args.session,
directory: cwd,
fetch: transport.fetch,
})
} catch (error) {
UI.error(errorMessage(error))
process.exitCode = 1
return
}
setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000).unref?.()
await launch({
...transport,
sessionID: args.session,
prompt,
stop,
onSnapshot: async () => {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
})
try {
const { createTuiRenderer, tui } = await import("./app")
const renderer = await createTuiRenderer(config)
const handle = tui({
url: transport.url,
renderer,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config,
directory: cwd,
fetch: transport.fetch,
events: transport.events,
args: {
continue: args.continue,
sessionID: args.session,
agent: args.agent,
model: args.model,
prompt,
fork: args.fork,
},
})
await handle.done
} finally {
await stop()
}
} finally {
unguard?.()
}
+1 -1
View File
@@ -1 +1 @@
export { evaluate } from "@opencode-ai/core/permission"
export { evaluate } from "."
+55 -137
View File
@@ -1,142 +1,57 @@
import { ConfigPermission } from "@/config/permission"
import { InstanceState } from "@/effect/instance-state"
import { ProjectV2 } from "@opencode-ai/core/project"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@opencode-ai/core/session/sql"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Wildcard } from "@opencode-ai/core/util/wildcard"
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { Deferred, Effect, Layer, Context } from "effect"
import os from "os"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionID } from "./schema"
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "permission" })
export const Action = PermissionV2.Action.annotate({ identifier: "PermissionAction" })
export type Action = Schema.Schema.Type<typeof Action>
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "PermissionRule" })
export type Rule = Schema.Schema.Type<typeof Rule>
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
// Pure data; nothing checks class identity. As `Schema.Struct` + type alias,
// `Permission.ask` can trust its already-typed input and skip the inner
// `decodeUnknownSync` that would otherwise throw uncaught on any structural
// mismatch. Same pattern as `Question.Request` in PR #28570.
export const Request = Schema.Struct({
id: PermissionID,
sessionID: SessionID,
permission: Schema.String,
patterns: Schema.Array(Schema.String),
metadata: Schema.Record(Schema.String, Schema.Unknown),
always: Schema.Array(Schema.String),
tool: Schema.optional(
Schema.Struct({
messageID: MessageID,
callID: Schema.String,
}),
),
}).annotate({ identifier: "PermissionRequest" })
export type Request = Schema.Schema.Type<typeof Request>
export const Reply = Schema.Literals(["once", "always", "reject"])
export type Reply = Schema.Schema.Type<typeof Reply>
const reply = {
reply: Reply,
message: Schema.optional(Schema.String),
}
export const ReplyBody = Schema.Struct(reply).annotate({ identifier: "PermissionReplyBody" })
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
export const Approval = Schema.Struct({
projectID: ProjectV2.ID,
patterns: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionApproval" })
export type Approval = Schema.Schema.Type<typeof Approval>
export const Event = {
Asked: EventV2.define({ type: "permission.asked", schema: Request.fields }),
Asked: EventV2.define({ type: "permission.asked", schema: PermissionLegacy.Request.fields }),
Replied: EventV2.define({
type: "permission.replied",
schema: {
sessionID: SessionID,
requestID: PermissionID,
reply: Reply,
sessionID: PermissionLegacy.Request.fields.sessionID,
requestID: PermissionLegacy.ID,
reply: PermissionLegacy.Reply,
},
}),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() {
return "The user rejected permission to use this specific tool call."
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Permission.NotFoundError", {
requestID: PermissionID,
}) {}
export type Error = DeniedError | RejectedError | CorrectedError
export const AskInput = Schema.Struct({
...Request.fields,
id: Schema.optional(PermissionID),
ruleset: Ruleset,
}).annotate({ identifier: "PermissionAskInput" })
export type AskInput = Schema.Schema.Type<typeof AskInput>
export const ReplyInput = Schema.Struct({
requestID: PermissionID,
...reply,
}).annotate({ identifier: "PermissionReplyInput" })
export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
export interface Interface {
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
readonly ask: (input: PermissionLegacy.AskInput) => Effect.Effect<void, PermissionLegacy.Error>
readonly reply: (input: PermissionLegacy.ReplyInput) => Effect.Effect<void, PermissionLegacy.NotFoundError>
readonly list: () => Effect.Effect<ReadonlyArray<PermissionLegacy.Request>>
}
interface PendingEntry {
info: Request
deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
info: PermissionLegacy.Request
deferred: Deferred.Deferred<void, PermissionLegacy.RejectedError | PermissionLegacy.CorrectedError>
}
interface State {
pending: Map<PermissionID, PendingEntry>
approved: Rule[]
pending: Map<PermissionLegacy.ID, PendingEntry>
approved: PermissionLegacy.Rule[]
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return PermissionV2.evaluate(permission, pattern, ...rulesets)
export function evaluate(
permission: string,
pattern: string,
...rulesets: PermissionLegacy.Ruleset[]
): PermissionLegacy.Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
}
)
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
@@ -145,24 +60,18 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const { db } = yield* Database.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) {
const row = yield* db
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, ctx.project.id))
.get()
.pipe(Effect.orDie)
void ctx
const state = {
pending: new Map<PermissionID, PendingEntry>(),
approved: [...(row?.data ?? [])],
pending: new Map<PermissionLegacy.ID, PendingEntry>(),
approved: [],
}
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const item of state.pending.values()) {
yield* Deferred.fail(item.deferred, new RejectedError())
yield* Deferred.fail(item.deferred, new PermissionLegacy.RejectedError())
}
state.pending.clear()
}),
@@ -172,7 +81,7 @@ export const layer = Layer.effect(
}),
)
const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
const ask = Effect.fn("Permission.ask")(function* (input: PermissionLegacy.AskInput) {
const { approved, pending } = yield* InstanceState.get(state)
const { ruleset, ...request } = input
let needsAsk = false
@@ -181,7 +90,7 @@ export const layer = Layer.effect(
const rule = evaluate(request.permission, pattern, ruleset, approved)
log.info("evaluated", { permission: request.permission, pattern, action: rule })
if (rule.action === "deny") {
return yield* new DeniedError({
return yield* new PermissionLegacy.DeniedError({
ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
})
}
@@ -191,8 +100,8 @@ export const layer = Layer.effect(
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
const id = request.id ?? PermissionLegacy.ID.ascending()
const info: PermissionLegacy.Request = {
id,
sessionID: request.sessionID,
permission: request.permission,
@@ -203,7 +112,7 @@ export const layer = Layer.effect(
}
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
const deferred = yield* Deferred.make<void, PermissionLegacy.RejectedError | PermissionLegacy.CorrectedError>()
pending.set(id, { info, deferred })
yield* events.publish(Event.Asked, info)
return yield* Effect.ensuring(
@@ -214,10 +123,10 @@ export const layer = Layer.effect(
)
})
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
const reply = Effect.fn("Permission.reply")(function* (input: PermissionLegacy.ReplyInput) {
const { approved, pending } = yield* InstanceState.get(state)
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
if (!existing) return yield* new PermissionLegacy.NotFoundError({ requestID: input.requestID })
pending.delete(input.requestID)
yield* events.publish(Event.Replied, {
@@ -229,7 +138,9 @@ export const layer = Layer.effect(
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
input.message
? new PermissionLegacy.CorrectedError({ feedback: input.message })
: new PermissionLegacy.RejectedError(),
)
for (const [id, item] of pending.entries()) {
@@ -240,7 +151,7 @@ export const layer = Layer.effect(
requestID: item.info.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new RejectedError())
yield* Deferred.fail(item.deferred, new PermissionLegacy.RejectedError())
}
return
}
@@ -290,7 +201,7 @@ function expand(pattern: string): string {
}
export function fromConfig(permission: ConfigPermission.Info) {
const ruleset: Rule[] = []
const ruleset: PermissionLegacy.Rule[] = []
for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") {
ruleset.push({ permission: key, action: value, pattern: "*" })
@@ -303,14 +214,21 @@ export function fromConfig(permission: ConfigPermission.Info) {
return ruleset
}
export function merge(...rulesets: Ruleset[]): Rule[] {
return [...PermissionV2.merge(...rulesets)]
export function merge(...rulesets: PermissionLegacy.Ruleset[]): PermissionLegacy.Rule[] {
return rulesets.flat()
}
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return PermissionV2.disabled(tools, ruleset)
export function disabled(tools: string[], ruleset: PermissionLegacy.Ruleset): Set<string> {
const edits = ["edit", "write", "apply_patch"]
return new Set(
tools.filter((tool) => {
const permission = edits.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
)
}
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
export * as Permission from "."
@@ -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 -8
View File
@@ -14,14 +14,7 @@ const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
const ALLOWED_MODELS = new Set([
"gpt-5.5",
"gpt-5.2",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.4",
"gpt-5.4-mini",
])
const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
interface PkceCodes {
verifier: string
+9 -41
View File
@@ -1,7 +1,7 @@
import { and, eq, sql } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -86,10 +86,6 @@ export function fromRow(row: Row): Info {
}
}
function mergePermissionRules<T extends readonly unknown[]>(oldRules: T, newRules: T): T {
return [...new Map([...oldRules, ...newRules].map((rule) => [JSON.stringify(rule), rule])).values()] as unknown as T
}
export const UpdateInput = Schema.Struct({
projectID: ProjectV2.ID,
name: Schema.optional(Schema.String),
@@ -201,36 +197,6 @@ export const layer = Layer.effect(
.run()
}
const oldPermission = yield* d
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, oldID))
.get()
const newPermission = yield* d
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, newID))
.get()
if (oldPermission && newPermission) {
yield* d
.update(PermissionTable)
.set({
data: mergePermissionRules(oldPermission.data, newPermission.data),
time_created: Math.min(oldPermission.time_created, newPermission.time_created),
time_updated: Date.now(),
})
.where(eq(PermissionTable.project_id, newID))
.run()
yield* d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
}
if (oldPermission && !newPermission) {
yield* d
.update(PermissionTable)
.set({ project_id: newID })
.where(eq(PermissionTable.project_id, oldID))
.run()
}
yield* d
.update(SessionTable)
.set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })
@@ -297,7 +263,7 @@ export const layer = Layer.effect(
.insert(ProjectTable)
.values({
id: result.id,
worktree: result.worktree,
worktree: AbsolutePath.make(result.worktree),
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
@@ -306,13 +272,13 @@ export const layer = Layer.effect(
time_created: result.time.created,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
sandboxes: result.sandboxes.map((sandbox) => AbsolutePath.make(sandbox)),
commands: result.commands,
})
.onConflictDoUpdate({
target: ProjectTable.id,
set: {
worktree: result.worktree,
worktree: AbsolutePath.make(result.worktree),
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
@@ -320,7 +286,7 @@ export const layer = Layer.effect(
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
sandboxes: result.sandboxes.map((sandbox) => AbsolutePath.make(sandbox)),
commands: result.commands,
},
})
@@ -451,8 +417,9 @@ export const layer = Layer.effect(
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectV2.ID, directory: string) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
if (!row) throw new Error(`Project not found: ${id}`)
const sandbox = AbsolutePath.make(directory)
const sboxes = [...row.sandboxes]
if (!sboxes.includes(directory)) sboxes.push(directory)
if (!sboxes.includes(sandbox)) sboxes.push(sandbox)
const result = yield* db
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
@@ -467,7 +434,8 @@ export const layer = Layer.effect(
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectV2.ID, directory: string) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = row.sandboxes.filter((s) => s !== directory)
const sandbox = AbsolutePath.make(directory)
const sboxes = row.sandboxes.filter((s) => s !== sandbox)
const result = yield* db
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
@@ -1,5 +1,5 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { PermissionNotFoundError } from "../errors"
@@ -10,7 +10,7 @@ import { described } from "./metadata"
const root = "/permission"
const ReplyPayload = Schema.Struct({
reply: Permission.Reply,
reply: PermissionLegacy.Reply,
message: Schema.optional(Schema.String),
})
@@ -20,7 +20,7 @@ export const PermissionApi = HttpApi.make("permission")
.add(
HttpApiEndpoint.get("list", root, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Permission.Request), "List of pending permissions"),
success: described(Schema.Array(PermissionLegacy.Request), "List of pending permissions"),
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.list",
@@ -29,7 +29,7 @@ export const PermissionApi = HttpApi.make("permission")
}),
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: PermissionID },
params: { requestID: PermissionLegacy.ID },
query: WorkspaceRoutingQuery,
payload: ReplyPayload,
success: described(Schema.Boolean, "Permission processed successfully"),
@@ -1,6 +1,6 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Permission } from "@/permission"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { PermissionID } from "@/permission/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
@@ -48,7 +48,7 @@ export const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
export const UpdatePayload = Schema.Struct({
title: Schema.optional(Schema.String),
metadata: Schema.optional(Session.Metadata),
permission: Schema.optional(Permission.Ruleset),
permission: Schema.optional(PermissionLegacy.Ruleset),
time: Schema.optional(
Schema.Struct({
archived: Schema.optional(Session.ArchivedTimestamp),
@@ -71,7 +71,7 @@ export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInp
export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"]))
export const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"]))
export const PermissionResponsePayload = Schema.Struct({
response: Permission.Reply,
response: PermissionLegacy.Reply,
})
export const SessionPaths = {
@@ -392,7 +392,7 @@ export const SessionApi = HttpApi.make("session")
}),
),
HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
params: { sessionID: SessionID, permissionID: PermissionID },
params: { sessionID: SessionID, permissionID: PermissionLegacy.ID },
query: WorkspaceRoutingQuery,
payload: PermissionResponsePayload,
success: described(Schema.Boolean, "Permission processed successfully"),
@@ -3,12 +3,16 @@ import { MessageGroup } from "./v2/message"
import { ModelGroup } from "./v2/model"
import { ProviderGroup } from "./v2/provider"
import { SessionGroup } from "./v2/session"
import { PermissionGroup, PermissionSavedGroup, SessionPermissionGroup } from "./v2/permission"
export const V2Api = HttpApi.make("v2")
.add(SessionGroup)
.add(MessageGroup)
.add(ModelGroup)
.add(ProviderGroup)
.add(PermissionGroup)
.add(SessionPermissionGroup)
.add(PermissionSavedGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
@@ -1,6 +1,7 @@
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { PluginBoot } from "@opencode-ai/core/plugin/boot"
import { Effect, Layer, Schema } from "effect"
@@ -34,7 +35,7 @@ export const locationQueryOpenApi = OpenApi.annotations({
export class V2LocationMiddleware extends HttpApiMiddleware.Service<
V2LocationMiddleware,
{
provides: Catalog.Service | PluginBoot.Service
provides: Catalog.Service | PluginBoot.Service | PermissionV2.Service
}
>()("@opencode/ExperimentalHttpApiV2Location") {}
@@ -59,4 +60,4 @@ export const layer = Layer.effect(
}),
)
}),
).pipe(Layer.provide(LocationServiceMap.layer))
)
@@ -0,0 +1,94 @@
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 { PermissionID } from "@/permission/schema"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
@@ -14,8 +14,8 @@ export const permissionHandlers = HttpApiBuilder.group(InstanceHttpApi, "permiss
})
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
params: { requestID: PermissionID }
payload: Permission.ReplyBody
params: { requestID: PermissionLegacy.ID }
payload: PermissionLegacy.ReplyBody
}) {
yield* svc
.reply({
@@ -1,9 +1,9 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Agent } from "@/agent/agent"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Command } from "@/command"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { SessionShare } from "@/share/session"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
@@ -360,7 +360,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
})
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
params: { sessionID: SessionID; permissionID: PermissionID }
params: { sessionID: SessionID; permissionID: PermissionLegacy.ID }
payload: typeof PermissionResponsePayload.Type
}) {
yield* requireSession(ctx.params.sessionID)
@@ -1,12 +1,25 @@
import { SessionV2 } from "@opencode-ai/core/session"
import { LocationServiceMap } from "@opencode-ai/core/location-layer"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Layer } from "effect"
import { layer as v2LocationLayer } from "../groups/v2/location"
import { messageHandlers } from "./v2/message"
import { modelHandlers } from "./v2/model"
import { providerHandlers } from "./v2/provider"
import { sessionHandlers } from "./v2/session"
import { permissionHandlers, savedPermissionHandlers, sessionPermissionHandlers } from "./v2/permission"
export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe(
export const v2Handlers = Layer.mergeAll(
sessionHandlers,
messageHandlers,
modelHandlers,
providerHandlers,
permissionHandlers,
sessionPermissionHandlers,
savedPermissionHandlers,
).pipe(
Layer.provide(v2LocationLayer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(PermissionSaved.layer),
Layer.provide(SessionV2.defaultLayer),
)
@@ -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 { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -15,7 +16,6 @@ import type { Agent } from "@/agent/agent"
import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Wildcard } from "@/util/wildcard"
@@ -38,7 +38,7 @@ export type StreamInput = {
parentSessionID?: string
model: Provider.Model
agent: Agent.Info
permission?: Permission.Ruleset
permission?: PermissionLegacy.Ruleset
system: string[]
messages: ModelMessage[]
small?: boolean
@@ -165,7 +165,7 @@ const live: Layer.Layer<
return { approved: true }
}
const id = PermissionID.ascending()
const id = PermissionLegacy.ID.ascending()
let unsub: EventV2.Unsubscribe | undefined
try {
unsub = await bridge.promise(
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import type { Auth } from "@/auth"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import type { RuntimeFlags } from "@/effect/runtime-flags"
@@ -22,7 +23,7 @@ type PrepareInput = {
readonly parentSessionID?: string
readonly model: Provider.Model
readonly agent: Agent.Info
readonly permission?: Permission.Ruleset
readonly permission?: PermissionLegacy.Ruleset
readonly system: string[]
readonly messages: ModelMessage[]
readonly small?: boolean
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Image } from "@/image/image"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
@@ -204,7 +205,7 @@ export const layer = Layer.effect(
time: { start: match.part.state.time.start, end: Date.now() },
},
})
if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {
if (error instanceof PermissionLegacy.RejectedError || error instanceof Question.RejectedError) {
ctx.blocked = ctx.shouldBreak
}
yield* settleToolCall(toolCallID)
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import path from "path"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import os from "os"
@@ -1220,7 +1221,7 @@ export const layer = Layer.effect(
const message = yield* createUserMessage(input)
yield* sessions.touch(input.sessionID)
const permissions: Permission.Rule[] = []
const permissions: PermissionLegacy.Rule[] = []
for (const [t, enabled] of Object.entries(input.tools ?? {})) {
permissions.push({ permission: t, action: enabled ? "allow" : "deny", pattern: "*" })
}
+15 -10
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Slug } from "@opencode-ai/core/util/slug"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
@@ -19,6 +20,7 @@ import { gte } from "drizzle-orm"
import { isNull } from "drizzle-orm"
import { desc } from "drizzle-orm"
import { like } from "drizzle-orm"
import { sql } from "drizzle-orm"
import { inArray } from "drizzle-orm"
import { lt } from "drizzle-orm"
import { or } from "drizzle-orm"
@@ -232,7 +234,7 @@ export const Info = Schema.Struct({
version: Schema.String,
metadata: optionalOmitUndefined(Metadata),
time: Time,
permission: optionalOmitUndefined(Permission.Ruleset),
permission: optionalOmitUndefined(PermissionLegacy.Ruleset),
revert: optionalOmitUndefined(Revert),
}).annotate({ identifier: "Session" })
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
@@ -257,7 +259,7 @@ export const CreateInput = Schema.optional(
agent: Schema.optional(Schema.String),
model: Schema.optional(Model),
metadata: Schema.optional(Metadata),
permission: Schema.optional(Permission.Ruleset),
permission: Schema.optional(PermissionLegacy.Ruleset),
workspaceID: Schema.optional(WorkspaceV2.ID),
}),
)
@@ -281,7 +283,7 @@ export const SetMetadataInput = Schema.Struct({
})
export const SetPermissionInput = Schema.Struct({
sessionID: SessionID,
permission: Permission.Ruleset,
permission: PermissionLegacy.Ruleset,
})
export const SetRevertInput = Schema.Struct({
sessionID: SessionID,
@@ -347,7 +349,7 @@ const UpdatedInfo = Schema.Struct({
version: Schema.optional(Schema.NullOr(Schema.String)),
metadata: Schema.optional(Schema.NullOr(Metadata)),
time: Schema.optional(UpdatedTime),
permission: Schema.optional(Schema.NullOr(Permission.Ruleset)),
permission: Schema.optional(Schema.NullOr(PermissionLegacy.Ruleset)),
revert: Schema.optional(Schema.NullOr(Revert)),
})
@@ -471,7 +473,7 @@ export interface Interface {
agent?: string
model?: Schema.Schema.Type<typeof Model>
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset
permission?: PermissionLegacy.Ruleset
workspaceID?: WorkspaceV2.ID
}) => Effect.Effect<Info>
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info, NotFound>
@@ -480,7 +482,7 @@ export interface Interface {
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setMetadata: (input: typeof SetMetadataInput.Type) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: PermissionLegacy.Ruleset }) => Effect.Effect<void>
readonly setRevert: (input: {
sessionID: SessionID
revert: Info["revert"]
@@ -569,7 +571,7 @@ export const layer: Layer.Layer<
directory: string
path?: string
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset
permission?: PermissionLegacy.Ruleset
}) {
const ctx = yield* InstanceState.context
const result: Info = {
@@ -747,7 +749,7 @@ export const layer: Layer.Layer<
agent?: string
model?: Schema.Schema.Type<typeof Model>
metadata?: typeof Metadata.Type
permission?: Permission.Ruleset
permission?: PermissionLegacy.Ruleset
workspaceID?: WorkspaceV2.ID
}) {
const ctx = yield* InstanceState.context
@@ -841,7 +843,7 @@ export const layer: Layer.Layer<
const setPermission = Effect.fn("Session.setPermission")(function* (input: {
sessionID: SessionID
permission: Permission.Ruleset
permission: PermissionLegacy.Ruleset
}) {
yield* patch(input.sessionID, { permission: [...input.permission], time: { updated: Date.now() } }).pipe(
Effect.orDie,
@@ -1047,7 +1049,10 @@ function listByProject(
}
if (input.path !== undefined) {
if (input.path) {
const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)]
const conds = [
eq(SessionTable.path, input.path),
like(SessionTable.path, sql.param(`${input.path}/%`, SessionTable.path)),
]
conditions.push(
input.directory
@@ -3,7 +3,7 @@ import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
import { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
import { SessionShareTable } from "@opencode-ai/core/share/sql"
import path from "path"
import { existsSync } from "fs"
@@ -108,13 +108,12 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
// Pre-scan all files upfront to avoid repeated glob operations
log.info("scanning files...")
const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, permFiles, shareFiles] = await Promise.all([
const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, shareFiles] = await Promise.all([
list("project/*.json"),
list("session/*/*.json"),
list("message/*/*.json"),
list("part/*/*.json"),
list("todo/*.json"),
list("permission/*.json"),
list("session_share/*.json"),
])
@@ -124,7 +123,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
messages: messageFiles.length,
parts: partFiles.length,
todos: todoFiles.length,
permissions: permFiles.length,
shares: shareFiles.length,
})
@@ -135,7 +133,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
messageFiles.length +
partFiles.length +
todoFiles.length +
permFiles.length +
shareFiles.length,
)
const progress = options?.progress
@@ -357,31 +354,6 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
log.warn("skipped orphaned todos", { count: orphans.todos })
}
// Migrate permissions
const permProjects = permFiles.map((file) => path.basename(file, ".json"))
const permValues: unknown[] = []
for (let i = 0; i < permFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, permFiles.length)
const batch = await read(permFiles, i, end)
permValues.length = 0
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
const projectID = permProjects[i + j]
if (!projectIds.has(projectID)) {
orphans.permissions++
continue
}
permValues.push({ project_id: projectID, data })
}
stats.permissions += insert(permValues, PermissionTable, "permission")
step("permissions", end - i)
}
log.info("migrated permissions", { count: stats.permissions })
if (orphans.permissions > 0) {
log.warn("skipped orphaned permissions", { count: orphans.permissions })
}
// Migrate session shares
const shareSessions = shareFiles.map((file) => path.basename(file, ".json"))
const shareValues: unknown[] = []
+1 -1
View File
@@ -1,5 +1,5 @@
export { AccountTable, AccountStateTable, ControlAccountTable } from "@opencode-ai/core/account/sql"
export { ProjectTable } from "@opencode-ai/core/project/sql"
export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
export { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
export { SessionShareTable } from "@opencode-ai/core/share/sql"
export { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
+2 -1
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Effect, Schema } from "effect"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import type { JSONSchema7 } from "@ai-sdk/provider"
@@ -41,7 +42,7 @@ export type Context<M extends Metadata = Metadata> = {
extra?: { [key: string]: unknown }
messages: SessionLegacy.WithParts[]
metadata(input: { title?: string; metadata?: M }): Effect.Effect<void>
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
ask(input: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">): Effect.Effect<void>
}
export interface ExecuteResult<M extends Metadata = Metadata> {
+2 -1
View File
@@ -9,6 +9,7 @@ import { Config } from "../../src/config/config"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { Global } from "@opencode-ai/core/global"
import { Permission } from "../../src/permission"
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { Plugin } from "../../src/plugin"
import { Provider } from "../../src/provider/provider"
import { Skill } from "../../src/skill"
@@ -28,7 +29,7 @@ const it = testEffect(agentLayer())
const scout = testEffect(agentLayer({ experimentalScout: true }))
// 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
return Permission.evaluate(permission, "*", agent.permission).action
}
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
/**
* 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
// ruleset, not the session). So we pass [] through as the parent
// session permission, exactly like the actual code path.
const parentSessionPermission: Permission.Ruleset = []
const parentSessionPermission: PermissionLegacy.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
@@ -88,7 +89,7 @@ it.instance("[#26514] explore subagent launched from plan mode also stays read-o
expect(planAgent).toBeDefined()
expect(explore).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const parentSessionPermission: PermissionLegacy.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
@@ -113,7 +114,7 @@ it.instance(
expect(planAgent).toBeDefined()
expect(my).toBeDefined()
const parentSessionPermission: Permission.Ruleset = []
const parentSessionPermission: PermissionLegacy.Ruleset = []
const subagentSessionPermission = deriveSubagentSessionPermission({
parentSessionPermission,
parentAgent: planAgent,
@@ -0,0 +1,278 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { Global } from "@opencode-ai/core/global"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { tmpdir } from "../../../fixture/fixture"
import { json, mount, wait } from "./sync-fixture"
const sessionID = "ses_hydration_race"
const messageID = "msg_hydration_race"
const partID = "prt_hydration_race"
const session = {
id: sessionID,
title: "race",
time: { created: 0, updated: 0 },
version: "1.15.13",
directory: "/tmp/opencode/packages/opencode",
}
const assistant = {
id: messageID,
sessionID,
role: "assistant" as const,
agent: "build",
modelID: "model",
providerID: "test",
mode: "build",
parentID: "msg_user",
path: { cwd: session.directory, root: session.directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, completed: 2 },
}
function global(payload: GlobalEvent["payload"]): GlobalEvent {
return { directory: "/tmp/other", project: "proj_test", payload }
}
test("stale session hydration does not overwrite live message parts", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
})
try {
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
emit(
global({
id: "evt_part",
type: "message.part.updated",
properties: {
sessionID,
time: 2,
part: { id: partID, sessionID, messageID, type: "text", text: "visible live content" },
},
}),
)
await wait(() => sync.data.part[messageID]?.[0]?.type === "text")
resolveMessages(
json([
{
info: assistant,
parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }],
},
]),
)
await hydrate
expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible live content" })
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})
test("orphan live deltas do not suppress hydrated parts", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
})
try {
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
emit(
global({
id: "evt_delta",
type: "message.part.delta",
properties: { sessionID, messageID, partID, field: "text", delta: "ignored until part exists" },
}),
)
resolveMessages(
json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "hydrated" }] }]),
)
await hydrate
expect(sync.data.part[messageID][0]).toMatchObject({ text: "hydrated" })
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})
test("hydration does not clear text streamed before it starts", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
})
try {
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
emit(
global({
id: "evt_part",
type: "message.part.updated",
properties: {
sessionID,
time: 1,
part: { id: partID, sessionID, messageID, type: "text", text: "" },
},
}),
)
emit(
global({
id: "evt_delta",
type: "message.part.delta",
properties: { sessionID, messageID, partID, field: "text", delta: "visible streamed content" },
}),
)
await wait(() => sync.data.part[messageID]?.[0]?.type === "text" && sync.data.part[messageID][0].text !== "")
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
resolveMessages(json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "" }] }]))
await hydrate
expect(sync.data.part[messageID][0]).toMatchObject({ text: "visible streamed content" })
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})
test("live messages merged during hydration retain the 100 message window", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
})
try {
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
const live = { ...assistant, id: "msg_z_live" }
emit(global({ id: "evt_live", type: "message.updated", properties: { sessionID, info: live } }))
await wait(() => sync.data.message[sessionID]?.some((message) => message.id === live.id) ?? false)
resolveMessages(
json(
Array.from({ length: 100 }, (_, index) => {
const id = `msg_${String(index).padStart(3, "0")}`
return {
info: { ...assistant, id },
parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text: id }],
}
}),
),
)
await hydrate
expect(sync.data.message[sessionID]).toHaveLength(100)
expect(sync.data.message[sessionID].at(-1)?.id).toBe(live.id)
expect(sync.data.message[sessionID].some((message) => message.id === "msg_000")).toBe(false)
expect(sync.data.part.msg_000).toBeUndefined()
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})
test("a message removed during hydration does not regain stale parts", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")
let resolveMessages!: (response: Response) => void
const messages = new Promise<Response>((resolve) => {
resolveMessages = resolve
})
let requested = false
const { app, emit, sync } = await mount((url) => {
if (url.pathname === `/session/${sessionID}`) return json(session)
if (url.pathname === `/session/${sessionID}/message`) {
requested = true
return messages
}
if (url.pathname === `/session/${sessionID}/todo` || url.pathname === `/session/${sessionID}/diff`) return json([])
return undefined
})
try {
emit(global({ id: "evt_message", type: "message.updated", properties: { sessionID, info: assistant } }))
await wait(() => sync.data.message[sessionID]?.length === 1)
const hydrate = sync.session.sync(sessionID)
await wait(() => requested)
emit(global({ id: "evt_removed", type: "message.removed", properties: { sessionID, messageID } }))
await wait(() => sync.data.message[sessionID]?.length === 0)
resolveMessages(
json([{ info: assistant, parts: [{ id: partID, sessionID, messageID, type: "text", text: "stale" }] }]),
)
await hydrate
expect(sync.data.message[sessionID]).toEqual([])
expect(sync.data.part[messageID]).toBeUndefined()
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})
@@ -1,98 +0,0 @@
import { describe, expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
import type { Message, Part, ToolPart } from "@opencode-ai/sdk/v2"
import { createDebugFrameTransport } from "../../../src/cli/cmd/tui/debug/frame"
const fixture = fileURLToPath(
new URL("../../../src/cli/cmd/tui/debug/fixtures/subagent-lifecycle.json", import.meta.url),
)
describe("TUI debug frames", () => {
test("compiles completed direct and child-tool subagents into distinct sessions", async () => {
const transport = await createDebugFrameTransport({ file: fixture, frame: "completed", directory: "/tmp/project" })
const transcript = (await (
await transport.fetch(`http://opencode.debug/session/${transport.sessionID}/message`)
).json()) as Array<{ info: Message; parts: Part[] }>
const tasks = transcript[1]!.parts.filter((part): part is ToolPart => part.type === "tool" && part.tool === "task")
const directID = completedMetadata(tasks[1]!).sessionId as string
const readID = completedMetadata(tasks[2]!).sessionId as string
const direct = (await (
await transport.fetch(`http://opencode.debug/session/${directID}/message`)
).json()) as Array<{ info: Message; parts: Part[] }>
const read = (await (await transport.fetch(`http://opencode.debug/session/${readID}/message`)).json()) as Array<{
info: Message
parts: Part[]
}>
expect(directID).not.toBe(readID)
expect(direct[1]!.parts).toHaveLength(0)
expect(read[1]!.parts.filter((part) => part.type === "tool")).toHaveLength(1)
if (direct[1]!.info.role !== "assistant" || direct[1]!.info.time.completed === undefined) {
throw new Error("Expected completed child response")
}
expect(direct[1]!.info.time.completed - direct[0]!.info.time.created).toBe(501)
})
test("marks only active background children busy", async () => {
const transport = await createDebugFrameTransport({
file: fixture,
frame: "active-background",
directory: "/tmp/project",
})
const status = (await (await transport.fetch("http://opencode.debug/session/status")).json()) as Record<
string,
{ type: string }
>
expect(Object.values(status)).toEqual([{ type: "busy" }])
})
test("compiles retrying and failed subagent states", async () => {
const retrying = await createDebugFrameTransport({ file: fixture, frame: "retrying", directory: "/tmp/project" })
const retryTranscript = (await (
await retrying.fetch(`http://opencode.debug/session/${retrying.sessionID}/message`)
).json()) as Array<{ parts: Part[] }>
const retryTask = retryTranscript[1]!.parts.find(
(part): part is ToolPart => part.type === "tool" && part.tool === "task",
)!
const retryID = runningMetadata(retryTask).sessionId as string
const status = (await (await retrying.fetch("http://opencode.debug/session/status")).json()) as Record<
string,
{ type: string; attempt?: number }
>
const failed = await createDebugFrameTransport({ file: fixture, frame: "failed", directory: "/tmp/project" })
const failedTranscript = (await (
await failed.fetch(`http://opencode.debug/session/${failed.sessionID}/message`)
).json()) as Array<{ parts: Part[] }>
const failedTask = failedTranscript[1]!.parts.find(
(part): part is ToolPart => part.type === "tool" && part.tool === "task",
)!
expect(status[retryID]).toMatchObject({ type: "retry", attempt: 2 })
expect(failedTask.state.status).toBe("error")
})
test("reports available frames for unknown selection", async () => {
await expect(
createDebugFrameTransport({ file: fixture, frame: "missing", directory: "/tmp/project" }),
).rejects.toThrow("Available frames: running, active-background, retrying, failed, completed")
})
test("rejects mutations against static debug frames", async () => {
const transport = await createDebugFrameTransport({ file: fixture, frame: "completed", directory: "/tmp/project" })
await expect(
transport.fetch(`http://opencode.debug/session/${transport.sessionID}/message`, { method: "POST" }),
).rejects.toThrow("Unexpected debug frame request: POST")
})
})
function completedMetadata(part: ToolPart) {
if (part.state.status !== "completed") throw new Error("Expected completed task")
return part.state.metadata
}
function runningMetadata(part: ToolPart) {
if (part.state.status !== "running") throw new Error("Expected running task")
return part.state.metadata ?? {}
}
@@ -157,9 +157,7 @@ describe("TUI inline tool wrapping", () => {
})
test("keeps retry status ahead of wrapping messages", () => {
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe(
"Retrying (attempt 2) · Rate limited by provider",
)
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
})
test("snapshots consecutive grep, glob, and read rows at a narrow width", async () => {
@@ -13,6 +13,7 @@ import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session as SessionNs } from "@/session/session"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
@@ -304,7 +305,7 @@ function insertProject(id: ProjectV2.ID, worktree: string) {
.insert(ProjectTable)
.values({
id,
worktree,
worktree: AbsolutePath.make(worktree),
vcs: null,
name: null,
time_created: Date.now(),
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { describe, test, expect } from "bun:test"
import { Effect } from "effect"
import { Permission } from "../src/permission"
@@ -9,7 +10,7 @@ const it = testEffect(Config.defaultLayer)
const load = Config.use.get()
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]) => ({
permission: "task",
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.
// 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`.
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]) => ({
permission: "task",
pattern,
+56 -50
View File
@@ -1,3 +1,4 @@
import { PermissionLegacy } from "@opencode-ai/core/permission/legacy"
import { test, expect } from "bun:test"
import os from "os"
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 { Database } from "@opencode-ai/core/database/database"
import { Permission } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store"
import { TestInstance, tmpdirScoped } from "../fixture/fixture"
@@ -261,8 +261,8 @@ test("merge - preserves rule order", () => {
})
test("merge - config permission overrides default ask", () => {
const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const defaults: PermissionLegacy.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const merged = Permission.merge(defaults, config)
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", () => {
const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const defaults: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const merged = Permission.merge(defaults, config)
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", () => {
const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
const config: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: PermissionLegacy.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
const result = Permission.evaluate("bash", "rm", config, approved)
expect(result.action).toBe("deny")
})
@@ -588,7 +588,7 @@ it.instance(
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
}),
{ git: true },
)
@@ -655,10 +655,10 @@ it.instance(
() =>
Effect.gen(function* () {
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) => {
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
})
yield* Effect.addFinalizer(() => unsub)
@@ -703,7 +703,7 @@ it.instance(
() =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test1"),
id: PermissionLegacy.ID.make("per_test1"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -713,7 +713,7 @@ it.instance(
}).pipe(Effect.forkScoped)
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)
}),
{ git: true },
@@ -724,7 +724,7 @@ it.instance(
() =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test2"),
id: PermissionLegacy.ID.make("per_test2"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -734,11 +734,11 @@ it.instance(
}).pipe(Effect.forkScoped)
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)
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 },
)
@@ -748,7 +748,7 @@ it.instance(
() =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test2b"),
id: PermissionLegacy.ID.make("per_test2b"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -759,7 +759,7 @@ it.instance(
yield* waitForPending(1)
yield* reply({
requestID: PermissionID.make("per_test2b"),
requestID: PermissionLegacy.ID.make("per_test2b"),
reply: "reject",
message: "Use a safer command",
})
@@ -768,7 +768,7 @@ it.instance(
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
expect(err).toBeInstanceOf(Permission.CorrectedError)
expect(err).toBeInstanceOf(PermissionLegacy.CorrectedError)
expect(String(err)).toContain("Use a safer command")
}
}),
@@ -780,7 +780,7 @@ it.instance(
() =>
Effect.gen(function* () {
const fiber = yield* ask({
id: PermissionID.make("per_test3"),
id: PermissionLegacy.ID.make("per_test3"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -790,7 +790,7 @@ it.instance(
}).pipe(Effect.forkScoped)
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)
const result = yield* ask({
@@ -811,7 +811,7 @@ it.instance(
() =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test4a"),
id: PermissionLegacy.ID.make("per_test4a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
@@ -821,7 +821,7 @@ it.instance(
}).pipe(Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_test4b"),
id: PermissionLegacy.ID.make("per_test4b"),
sessionID: SessionID.make("session_same"),
permission: "edit",
patterns: ["foo.ts"],
@@ -831,13 +831,13 @@ it.instance(
}).pipe(Effect.forkScoped)
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)])
expect(Exit.isFailure(ea)).toBe(true)
expect(Exit.isFailure(eb)).toBe(true)
if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(Permission.RejectedError)
if (Exit.isFailure(eb)) expect(Cause.squash(eb.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(PermissionLegacy.RejectedError)
}),
{ git: true },
)
@@ -847,7 +847,7 @@ it.instance(
() =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test5a"),
id: PermissionLegacy.ID.make("per_test5a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
@@ -857,7 +857,7 @@ it.instance(
}).pipe(Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_test5b"),
id: PermissionLegacy.ID.make("per_test5b"),
sessionID: SessionID.make("session_same"),
permission: "bash",
patterns: ["ls"],
@@ -867,7 +867,7 @@ it.instance(
}).pipe(Effect.forkScoped)
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(b)
@@ -881,7 +881,7 @@ it.instance(
() =>
Effect.gen(function* () {
const a = yield* ask({
id: PermissionID.make("per_test6a"),
id: PermissionLegacy.ID.make("per_test6a"),
sessionID: SessionID.make("session_a"),
permission: "bash",
patterns: ["ls"],
@@ -891,7 +891,7 @@ it.instance(
}).pipe(Effect.forkScoped)
const b = yield* ask({
id: PermissionID.make("per_test6b"),
id: PermissionLegacy.ID.make("per_test6b"),
sessionID: SessionID.make("session_b"),
permission: "bash",
patterns: ["ls"],
@@ -901,10 +901,10 @@ it.instance(
}).pipe(Effect.forkScoped)
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)
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* Fiber.await(b)
@@ -917,10 +917,14 @@ it.instance(
() =>
Effect.gen(function* () {
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({
id: PermissionID.make("per_test7"),
id: PermissionLegacy.ID.make("per_test7"),
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -935,13 +939,15 @@ it.instance(
if (event.type === Permission.Event.Replied.type)
Deferred.doneUnsafe(
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
})
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)
expect(
yield* Deferred.await(seen).pipe(
@@ -952,7 +958,7 @@ it.instance(
),
).toEqual({
sessionID: SessionID.make("session_test"),
requestID: PermissionID.make("per_test7"),
requestID: PermissionLegacy.ID.make("per_test7"),
reply: "once",
})
}),
@@ -969,7 +975,7 @@ it.live("permission requests stay isolated by directory", () =>
.provide(
{ directory: one },
ask({
id: PermissionID.make("per_dir_a"),
id: PermissionLegacy.ID.make("per_dir_a"),
sessionID: SessionID.make("session_dir_a"),
permission: "bash",
patterns: ["ls"],
@@ -984,7 +990,7 @@ it.live("permission requests stay isolated by directory", () =>
.provide(
{ directory: two },
ask({
id: PermissionID.make("per_dir_b"),
id: PermissionLegacy.ID.make("per_dir_b"),
sessionID: SessionID.make("session_dir_b"),
permission: "bash",
patterns: ["pwd"],
@@ -1000,8 +1006,8 @@ it.live("permission requests stay isolated by directory", () =>
expect(onePending).toHaveLength(1)
expect(twoPending).toHaveLength(1)
expect(onePending[0].id).toBe(PermissionID.make("per_dir_a"))
expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b"))
expect(onePending[0].id).toBe(PermissionLegacy.ID.make("per_dir_a"))
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: two }, reply({ requestID: twoPending[0].id, reply: "reject" }))
@@ -1018,7 +1024,7 @@ it.instance(
const test = yield* TestInstance
const store = yield* InstanceStore.Service
const fiber = yield* ask({
id: PermissionID.make("per_dispose"),
id: PermissionLegacy.ID.make("per_dispose"),
sessionID: SessionID.make("session_dispose"),
permission: "bash",
patterns: ["ls"],
@@ -1033,7 +1039,7 @@ it.instance(
const exit = yield* Fiber.await(fiber)
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 },
)
@@ -1045,7 +1051,7 @@ it.instance(
const test = yield* TestInstance
const store = yield* InstanceStore.Service
const fiber = yield* ask({
id: PermissionID.make("per_reload"),
id: PermissionLegacy.ID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
@@ -1059,7 +1065,7 @@ it.instance(
const exit = yield* Fiber.await(fiber)
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 },
)
@@ -1068,7 +1074,7 @@ it.instance(
"reply - fails for unknown requestID",
() =>
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)
if (Exit.isFailure(exit)) {
expect(Cause.squash(exit.cause)).toMatchObject({ _tag: "Permission.NotFoundError", requestID: "per_unknown" })
@@ -1095,7 +1101,7 @@ it.instance(
],
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
}),
{ git: true },
)
@@ -1135,7 +1141,7 @@ it.instance(
}),
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(err).toBeInstanceOf(PermissionLegacy.DeniedError)
expect(yield* list()).toHaveLength(0)
}),
{ git: true },
@@ -1149,7 +1155,7 @@ it.instance(
const store = yield* InstanceStore.Service
const fiber = yield* ask({
id: PermissionID.make("per_reload"),
id: PermissionLegacy.ID.make("per_reload"),
sessionID: SessionID.make("session_reload"),
permission: "bash",
patterns: ["ls"],
@@ -1164,7 +1170,7 @@ it.instance(
const exit = yield* Fiber.await(fiber)
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 },
)
@@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
@@ -48,7 +49,7 @@ function ensureGlobal() {
.insert(ProjectTable)
.values({
id: ProjectV2.ID.global,
worktree: "/",
worktree: AbsolutePath.make("/"),
time_created: Date.now(),
time_updated: Date.now(),
sandboxes: [],
+1 -19
View File
@@ -8,7 +8,7 @@ import { tmpdirScoped } from "../fixture/fixture"
import { GlobalBus } from "../../src/bus/global"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { eq } from "drizzle-orm"
import { Hash } from "@opencode-ai/core/util/hash"
@@ -218,16 +218,6 @@ describe("Project.fromDirectory", () => {
})
.run()
.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
.insert(WorkspaceTable)
.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))
?.project_id,
).toBe(remoteID)
expect(
yield* db
.select()
.from(PermissionTable)
.where(eq(PermissionTable.project_id, remoteID))
.get()
.pipe(Effect.orDie),
).toBeDefined()
expect(
(yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie))
?.project_id,
@@ -579,6 +579,32 @@ const scenarios: Scenario[] = [
.get("/api/provider/{providerID}", "v2.provider.get")
.at((ctx) => ({ path: route("/api/provider/{providerID}", { providerID: "missing" }), headers: ctx.headers() }))
.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
.get("/api/session", "v2.session.list")
.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 { Flag } from "@opencode-ai/core/flag/flag"
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 { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { PermissionID } from "../../src/permission/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { QuestionID } from "../../src/question/schema"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
@@ -167,7 +167,7 @@ describe("instance HttpApi", () => {
handlerContext,
),
)
const permissionID = PermissionID.ascending()
const permissionID = PermissionLegacy.ID.ascending()
const questionReplyID = QuestionID.ascending()
const questionRejectID = QuestionID.ascending()
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 { NodeHttpServer, NodeServices } from "@effect/platform-node"
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 type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service"
@@ -913,7 +913,7 @@ describe("session HttpApi", () => {
}),
).toMatchObject({ id: session.id })
const permissionID = String(PermissionID.ascending())
const permissionID = String(PermissionLegacy.ID.ascending())
const permission = yield* request(
pathFor(SessionPaths.permissions, {
sessionID: session.id,
@@ -99,6 +99,33 @@ describe("session.list", () => {
{ git: true },
)
it.instance(
"matches a session regardless of directory separator on Windows",
() =>
Effect.gen(function* () {
if (process.platform !== "win32") return
const test = yield* TestInstance
const dir = path.join(test.directory, "packages", "opencode")
yield* Effect.promise(() => mkdir(dir, { recursive: true }))
const created = yield* withSession({ title: "separator" }).pipe(provideInstance(dir))
// A forward-slash query (e.g. from the SDK/HTTP layer) must still find it —
// this is the regression: backslash-stored vs forward-slash-queried.
const forwardIDs = (yield* SessionNs.Service.use((session) =>
session.list({ directory: dir.replaceAll("\\", "/") }),
)).map((session) => session.id)
expect(forwardIDs).toContain(created.id)
// The native form must keep matching too.
const nativeIDs = (yield* SessionNs.Service.use((session) => session.list({ directory: dir }))).map(
(session) => session.id,
)
expect(nativeIDs).toContain(created.id)
}),
{ git: true },
)
it.instance(
"filters by path and ignores directory when path is provided",
() =>
@@ -132,6 +159,14 @@ describe("session.list", () => {
expect(pathIDs).toContain(current.id)
expect(pathIDs).toContain(deeper.id)
expect(pathIDs).not.toContain(sibling.id)
if (process.platform === "win32") {
const windowsPathIDs = (yield* SessionNs.Service.use((session) =>
session.list({ path: "packages\\opencode\\src" }),
)).map((session) => session.id)
expect(windowsPathIDs).toContain(current.id)
expect(windowsPathIDs).toContain(deeper.id)
}
}),
{ git: true },
)
+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 { SessionLegacy } from "@opencode-ai/core/session/legacy"
import path from "path"
@@ -332,7 +333,7 @@ describe("session.llm.ai-sdk adapter", () => {
})
test("preserves tool-error cause", async () => {
const error = new Permission.RejectedError()
const error = new PermissionLegacy.RejectedError()
const events = await Effect.runPromise(
LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), {
type: "tool-error",
@@ -9,7 +9,8 @@ import { JsonMigration } from "@/storage/json-migration"
import { Global } from "@opencode-ai/core/global"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
import { SessionShareTable } from "@opencode-ai/core/share/sql"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
@@ -128,9 +129,39 @@ describe("JSON to SQLite migration", () => {
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectV2.ID.make("proj_test123abc"))
expect(projects[0].worktree).toBe("/test/path")
expect(projects[0].worktree).toBe(AbsolutePath.make("/test/path"))
expect(projects[0].name).toBe("Test Project")
expect(projects[0].sandboxes).toEqual(["/test/sandbox"])
expect(projects[0].sandboxes).toEqual([AbsolutePath.make("/test/sandbox")])
})
test("stores imported Windows project and session paths in storage form", async () => {
if (process.platform !== "win32") return
await writeProject(storageDir, {
id: "proj_test123abc",
worktree: "C:\\Repo\\Thing",
vcs: "git",
sandboxes: ["C:\\Repo\\Thing\\sandbox"],
})
await writeSession(storageDir, "proj_test123abc", {
id: "ses_test456def",
slug: "storage-path",
directory: "C:\\Repo\\Thing\\packages\\api",
path: "packages\\api",
title: "Storage Path",
version: "test",
})
await JsonMigration.run(db)
expect(sqlite.query("SELECT worktree, sandboxes FROM project WHERE id = ?").get("proj_test123abc")).toEqual({
worktree: "C:/Repo/Thing",
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
})
expect(sqlite.query("SELECT directory, path FROM session WHERE id = ?").get("ses_test456def")).toEqual({
directory: "C:/Repo/Thing/packages/api",
path: "packages/api",
})
})
test("uses filename for project id when JSON has different value", async () => {
@@ -543,7 +574,7 @@ describe("JSON to SQLite migration", () => {
expect(todos[2].position).toBe(2)
})
test("migrates permissions", async () => {
test("does not migrate legacy permissions", async () => {
await writeProject(storageDir, {
id: "proj_test123abc",
worktree: "/",
@@ -561,12 +592,7 @@ describe("JSON to SQLite migration", () => {
const stats = await JsonMigration.run(db)
expect(stats?.permissions).toBe(1)
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)
expect(stats?.permissions).toBe(0)
})
test("migrates session shares", async () => {
@@ -663,7 +689,7 @@ describe("JSON to SQLite migration", () => {
expect(todos[1].position).toBe(2)
})
test("skips orphaned todos, permissions, and shares", async () => {
test("skips orphaned todos and shares", async () => {
await writeProject(storageDir, {
id: "proj_test123abc",
worktree: "/",
@@ -702,11 +728,10 @@ describe("JSON to SQLite migration", () => {
const stats = await JsonMigration.run(db)
expect(stats.todos).toBe(1)
expect(stats.permissions).toBe(1)
expect(stats.permissions).toBe(0)
expect(stats.shares).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)
})
@@ -817,7 +842,7 @@ describe("JSON to SQLite migration", () => {
expect(stats.messages).toBe(1)
expect(stats.parts).toBe(1)
expect(stats.todos).toBe(1)
expect(stats.permissions).toBe(1)
expect(stats.permissions).toBe(0)
expect(stats.shares).toBe(1)
expect(stats.errors.length).toBeGreaterThanOrEqual(6)
@@ -826,7 +851,6 @@ describe("JSON to SQLite migration", () => {
expect(db.select().from(MessageTable).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(PermissionTable).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 path from "path"
import { Effect } from "effect"
@@ -26,7 +27,7 @@ const glob = (p: string) =>
process.platform === "win32" ? Filesystem.normalizePathPattern(p) : p.replaceAll("\\", "/")
function makeCtx() {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
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 path from "path"
import { Cause, Effect, Exit, Layer } from "effect"
@@ -52,12 +53,12 @@ const ctx = {
}
const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const items: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
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 fs from "fs/promises"
import os from "os"
@@ -186,7 +187,7 @@ describe("tool.grep", () => {
[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 = {
...ctx,
ask: (req) =>
@@ -234,7 +235,7 @@ describe("tool.grep", () => {
yield* appfs.makeDirectory(remoteDir, { recursive: true }).pipe(Effect.orDie)
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 = {
...ctx,
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 { Effect, Layer } from "effect"
import path from "path"
@@ -83,12 +84,12 @@ const put = Effect.fn("LspToolTest.put")(function* (file: string) {
})
const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const items: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
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 { Cause, Effect, Exit, Layer, Stream } from "effect"
import path from "path"
@@ -140,12 +141,12 @@ const load = Effect.fn("ReadToolTest.load")(function* (p: string) {
return yield* fs.readFileString(p)
})
const asks = () => {
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const items: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
return {
items,
next: {
...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
items.push(req)
}),
@@ -328,7 +329,7 @@ describe("tool.read env file permissions", () => {
let asked = false
const next = {
...ctx,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, info.permission)
@@ -336,7 +337,7 @@ describe("tool.read env file permissions", () => {
asked = true
}
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 { Cause, Effect, Exit, Layer } from "effect"
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,
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
ask: (req: Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">) =>
Effect.sync(() => {
requests.push(req)
if (stop) throw stop
@@ -222,7 +223,7 @@ describe("tool.shell permissions", () => {
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "echo hello",
@@ -244,7 +245,7 @@ describe("tool.shell permissions", () => {
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "echo foo && echo bar",
@@ -268,7 +269,7 @@ describe("tool.shell permissions", () => {
runIn(
projectRoot,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "Write-Host foo; if ($?) { Write-Host bar }",
@@ -297,7 +298,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
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(
yield* fail(
{
@@ -323,7 +324,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
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 want = process.platform === "win32" ? glob(path.join(process.env.WINDIR!, "*")) : "/etc/*"
expect(
@@ -354,7 +355,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
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(
{
command: `echo $(cat "${file}")`,
@@ -383,7 +384,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
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(
yield* fail(
{
@@ -409,7 +410,7 @@ describe("tool.shell permissions", () => {
runIn(
projectRoot,
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`
yield* run(
{
@@ -440,7 +441,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
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(
yield* fail(
{
@@ -468,7 +469,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
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(
yield* fail(
{
@@ -497,7 +498,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
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(
yield* fail(
{
@@ -525,7 +526,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
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(
yield* fail(
{
@@ -560,7 +561,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
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(/[\\/]+$/, "")
expect(
yield* fail(
@@ -593,7 +594,7 @@ describe("tool.shell permissions", () => {
runIn(
projectRoot,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "Get-Content $env:WINDIR/win.ini",
@@ -620,7 +621,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
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(
yield* fail(
{
@@ -649,7 +650,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
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(
yield* fail(
{
@@ -677,7 +678,7 @@ describe("tool.shell permissions", () => {
runIn(
projectRoot,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "Set-Location C:/Windows",
@@ -705,7 +706,7 @@ describe("tool.shell permissions", () => {
runIn(
projectRoot,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "Write-Output ('a' * 3)",
@@ -731,7 +732,7 @@ describe("tool.shell permissions", () => {
runIn(
projectRoot,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`,
@@ -755,7 +756,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
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(
yield* fail(
{
@@ -779,7 +780,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
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(
yield* fail(
{
@@ -810,7 +811,7 @@ describe("tool.shell permissions", () => {
const want = Filesystem.normalizePathPattern(path.join(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(
yield* fail(
{
@@ -842,7 +843,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
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(), "*"))
expect(
yield* fail(
@@ -871,7 +872,7 @@ describe("tool.shell permissions", () => {
projectRoot,
Effect.gen(function* () {
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(), "*"))
expect(
yield* fail(
@@ -903,7 +904,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
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")
expect(
yield* fail(
@@ -931,7 +932,7 @@ describe("tool.shell permissions", () => {
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: `rm -rf ${path.join(tmp, "nested")}`,
@@ -952,7 +953,7 @@ describe("tool.shell permissions", () => {
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "git log --oneline -5",
@@ -974,7 +975,7 @@ describe("tool.shell permissions", () => {
yield* runIn(
tmp,
Effect.gen(function* () {
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<PermissionLegacy.Request, "id" | "sessionID" | "tool">> = []
yield* run(
{
command: "cd .",
@@ -996,7 +997,7 @@ describe("tool.shell permissions", () => {
tmp,
Effect.gen(function* () {
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(
yield* fail(
{ command: "echo test > output.txt", description: "Redirect test output" },
@@ -1017,7 +1018,7 @@ describe("tool.shell permissions", () => {
yield* runIn(
tmp,
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))
const bashReq = requests.find((r) => r.permission === "bash")
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 { Cause, Effect, Exit, Layer } from "effect"
import { afterEach, describe, expect } from "bun:test"
@@ -67,7 +68,7 @@ Use this skill.
})).find((tool) => tool.id === SkillTool.id)
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 = {
...baseCtx,
ask: (req) =>
+177
View File
@@ -117,6 +117,7 @@ import type {
PermissionRespondErrors,
PermissionRespondResponses,
PermissionRuleset,
PermissionV2Reply,
ProjectCurrentErrors,
ProjectCurrentResponses,
ProjectInitGitErrors,
@@ -248,6 +249,12 @@ import type {
TuiSubmitPromptResponses,
V2ModelListErrors,
V2ModelListResponses,
V2PermissionRequestListErrors,
V2PermissionRequestListResponses,
V2PermissionSavedListErrors,
V2PermissionSavedListResponses,
V2PermissionSavedRemoveErrors,
V2PermissionSavedRemoveResponses,
V2ProviderGetErrors,
V2ProviderGetResponses,
V2ProviderListErrors,
@@ -260,6 +267,10 @@ import type {
V2SessionListResponses,
V2SessionMessagesErrors,
V2SessionMessagesResponses,
V2SessionPermissionListErrors,
V2SessionPermissionListResponses,
V2SessionPermissionReplyErrors,
V2SessionPermissionReplyResponses,
V2SessionPromptErrors,
V2SessionPromptResponses,
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 {
/**
* List v2 sessions
@@ -4474,6 +4553,11 @@ export class Session3 extends HeyApiClient {
...params,
})
}
private _permission?: Permission2
get permission(): Permission2 {
return (this._permission ??= new Permission2({ client: this.client }))
}
}
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 {
private _session?: Session3
get session(): Session3 {
@@ -4572,6 +4744,11 @@ export class V2 extends HeyApiClient {
get provider(): Provider2 {
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 {
+319 -64
View File
@@ -44,10 +44,10 @@ export type Event =
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventMessagePartDelta
| EventPermissionAsked
| EventPermissionReplied
| EventSessionDiff
| EventSessionError
| EventPermissionAsked
| EventPermissionReplied
| EventQuestionAsked
| EventQuestionReplied
| EventQuestionRejected
@@ -78,6 +78,8 @@ export type Event =
| EventInstallationUpdateAvailable
| EventServerConnected
| EventGlobalDisposed
| EventPermissionV2Asked
| EventPermissionV2Replied
| EventAccountAdded
| EventAccountRemoved
| EventAccountSwitched
@@ -145,6 +147,16 @@ export type SnapshotFileDiff = {
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 = {
id: string
slug: string
@@ -1094,6 +1106,29 @@ export type GlobalEvent = {
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
type: "permission.asked"
@@ -1121,29 +1156,6 @@ export type GlobalEvent = {
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
type: "question.asked"
@@ -1415,6 +1427,30 @@ export type GlobalEvent = {
[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
type: "account.added"
@@ -2029,16 +2065,6 @@ export type WorktreeResetInput = {
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 = {
id: string
name?: string
@@ -2468,7 +2494,7 @@ export type SessionBusyError = {
}
export type V2SessionsResponse = {
items: Array<SessionInfo>
items: Array<SessionV2Info>
cursor: {
previous?: string
next?: 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 = {
type: "oauth"
refresh: string
@@ -3335,12 +3369,15 @@ export type ConfigV2ExperimentalPolicy = {
resource: string
}
export type SessionInfo = {
export type LocationRef = {
directory: string
workspaceID?: string
}
export type SessionV2Info = {
id: string
parentID?: string
projectID: string
workspaceID?: string
path?: string
agent?: string
model?: {
id: string
@@ -3363,6 +3400,8 @@ export type SessionInfo = {
archived?: number
}
title: string
location: LocationRef
subpath?: string
}
export type SessionDelivery = "immediate" | "deferred"
@@ -3637,6 +3676,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 = {
id: string
type: "models-dev.refreshed"
@@ -4173,6 +4231,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 = {
id: string
type: "permission.asked"
@@ -4202,31 +4285,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 = {
id: string
type: "question.asked"
@@ -4473,6 +4531,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 = {
id: string
type: "account.added"
@@ -8262,6 +8346,177 @@ export type 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 = {
body?: {
text: string
+847 -232
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -23,6 +23,7 @@
"./icons/app": "./src/components/app-icons/types.ts",
"./fonts/*": "./src/assets/fonts/*",
"./audio/*": "./src/assets/audio/*",
"./v2/*.css": "./src/v2/components/*.css",
"./v2/*": "./src/v2/components/*.tsx",
"./v2/styles/*": "./src/v2/styles/*"
},