Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 105 additions & 96 deletions packages/client/src/effect/api/api.ts

Large diffs are not rendered by default.

231 changes: 121 additions & 110 deletions packages/client/src/effect/generated/client.ts

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions packages/client/src/promise/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import type {
SessionSwitchAgentOutput,
SessionSwitchModelInput,
SessionSwitchModelOutput,
SessionConfigureInput,
SessionConfigureOutput,
SessionRenameInput,
SessionRenameOutput,
SessionPromptInput,
Expand Down Expand Up @@ -473,6 +475,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
configure: (input: SessionConfigureInput, requestOptions?: RequestOptions) =>
request<SessionConfigureOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/configure`,
body: { tools: input["tools"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
rename: (input: SessionRenameInput, requestOptions?: RequestOptions) =>
request<SessionRenameOutput>(
{
Expand Down
59 changes: 59 additions & 0 deletions packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,11 @@ export type SessionListOutput = {
readonly patch: string
}>
}
readonly tools?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
}>
readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
}
Expand Down Expand Up @@ -417,6 +422,11 @@ export type SessionCreateOutput = {
readonly patch: string
}>
}
readonly tools?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
}
}["data"]

Expand Down Expand Up @@ -456,6 +466,11 @@ export type SessionGetOutput = {
readonly patch: string
}>
}
readonly tools?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
}
}["data"]

Expand Down Expand Up @@ -500,6 +515,11 @@ export type SessionForkOutput = {
readonly patch: string
}>
}
readonly tools?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
}
}["data"]

Expand All @@ -519,6 +539,13 @@ export type SessionSwitchModelInput = {

export type SessionSwitchModelOutput = void

export type SessionConfigureInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly tools?: { readonly tools?: { readonly [x: string]: boolean } | undefined }["tools"]
}

export type SessionConfigureOutput = void

export type SessionRenameInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly title: { readonly title: string }["title"]
Expand Down Expand Up @@ -1168,6 +1195,22 @@ export type SessionLogOutput =
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.tools.configured"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly tools?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
}
}
| {
readonly id: string
readonly created: number
Expand Down Expand Up @@ -4467,6 +4510,22 @@ export type EventSubscribeOutput =
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.tools.configured"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly tools?: ReadonlyArray<{
readonly action: string
readonly resource: string
readonly effect: "allow" | "deny" | "ask"
}>
}
}
| {
readonly id: string
readonly created: number
Expand Down
14 changes: 12 additions & 2 deletions packages/core/schema.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "b0355fd9-bf41-42e3-9dca-76107de27ecd",
"prevIds": ["95328a41-789d-44de-9643-6ac6ecd6b4ec"],
"id": "b83ca28c-b8a4-4245-ab57-5bbb08fdb0bc",
"prevIds": ["b0355fd9-bf41-42e3-9dca-76107de27ecd"],
"ddl": [
{
"name": "workspace",
Expand Down Expand Up @@ -1384,6 +1384,16 @@
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "tool_permissions",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"

export default {
id: "20260707075505_add-session-tool-permissions",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`tool_permissions\` text;`)
})
},
} satisfies DatabaseMigration.Migration
1 change: 1 addition & 0 deletions packages/core/src/database/schema.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export default {
\`tokens_cache_write\` integer DEFAULT 0 NOT NULL,
\`revert\` text,
\`permission\` text,
\`tool_permissions\` text,
\`agent\` text,
\`model\` text,
\`time_created\` integer NOT NULL,
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ export interface Interface {
sessionID: SessionSchema.ID
model: ModelV2.Ref
}) => Effect.Effect<void, NotFoundError>
readonly configure: (input: {
sessionID: SessionSchema.ID
tools?: Record<string, boolean>
}) => Effect.Effect<void, NotFoundError>
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
readonly prompt: (input: {
id?: SessionMessage.ID
Expand Down Expand Up @@ -626,6 +630,20 @@ const layer = Layer.effect(
model: input.model,
})
}),
configure: Effect.fn("V2Session.configure")(function* (input) {
yield* result.get(input.sessionID)
const tools = input.tools
? Object.entries(input.tools).map(([action, allowed]) => ({
action,
resource: "*" as const,
effect: allowed ? ("allow" as const) : ("deny" as const),
}))
: undefined
yield* events.publish(SessionEvent.ToolsConfigured, {
sessionID: input.sessionID,
tools,
})
}),
rename: Effect.fn("V2Session.rename")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.Renamed, {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
revert: row.revert ? { ...row.revert, messageID: SessionMessage.ID.make(row.revert.messageID) } : undefined,
tools: row.tool_permissions ?? undefined,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/message-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.renamed": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.tools.configured": () => Effect.void,
"session.prompt.promoted": () => Effect.void,
"session.prompt.admitted": () => Effect.void,
"session.execution.started": () => Effect.void,
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/session/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { Slug } from "../util/slug"
type DatabaseService = Database.Interface["db"]
type MessageEvent = Exclude<
SessionEvent.DurableEvent,
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type | typeof SessionEvent.ToolsConfigured.Type
>

const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
Expand Down Expand Up @@ -623,6 +623,14 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionEvent.ToolsConfigured, (event) =>
db
.update(SessionTable)
.set({ tool_permissions: event.data.tools ?? null, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.Renamed, (event) =>
db
.update(SessionTable)
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,8 @@ const layer = Layer.effect(
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
const context = entries.map((entry) => entry.message)
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
const toolMaterialization = isLastStep
? undefined
: yield* tools.materialize({ permissions: agent.info?.permissions, model })
const permissions = [...(agent.info?.permissions ?? []), ...(session.tools ?? [])]
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize({ permissions, model })
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/session/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { SessionMessage } from "./message"
import type { Prompt } from "@opencode-ai/schema/prompt"
import type { SessionInput } from "./input"
import type { Snapshot } from "../snapshot"
import type { Permission } from "@opencode-ai/schema/permission"
import { PermissionV1 } from "../v1/permission"
import { ProjectV2 } from "../project"
import type { SessionSchema } from "./schema"
Expand Down Expand Up @@ -51,6 +52,7 @@ export const SessionTable = sqliteTable(
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<Revert.State>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
tool_permissions: text({ mode: "json" }).$type<Permission.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
id: string
Expand Down
19 changes: 19 additions & 0 deletions packages/protocol/src/groups/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,25 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.configure", "/api/session/:sessionID/configure", {
params: { sessionID: Session.ID },
payload: Schema.Struct({
tools: Schema.Record(Schema.String, Schema.Boolean).pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.configure",
summary: "Configure session",
description:
"Configure session-scoped tool availability. Tools set to false are denied; tools set to true are explicitly allowed. Omit tools or pass null to clear session overrides.",
}),
),
)
.add(
HttpApiEndpoint.post("session.rename", "/api/session/:sessionID/rename", {
params: { sessionID: Session.ID },
Expand Down
12 changes: 12 additions & 0 deletions packages/schema/src/session-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Delivery } from "./session-delivery.js"
import { Model } from "./model.js"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema.js"
import { FileAttachment, Prompt } from "./prompt.js"
import { Permission } from "./permission.js"
import { SessionID } from "./session-id.js"
import { Location } from "./location.js"
import { SessionMessage } from "./session-message.js"
Expand Down Expand Up @@ -63,6 +64,16 @@ export const ModelSelected = Event.durable({
})
export type ModelSelected = typeof ModelSelected.Type

export const ToolsConfigured = Event.durable({
type: "session.tools.configured",
...options,
schema: {
...Base,
tools: Permission.Ruleset.pipe(optional),
},
})
export type ToolsConfigured = typeof ToolsConfigured.Type

export const Moved = Event.durable({
type: "session.moved",
...options,
Expand Down Expand Up @@ -502,6 +513,7 @@ export namespace RevertEvent {
export const Definitions = Event.inventory(
AgentSelected,
ModelSelected,
ToolsConfigured,
Moved,
Renamed,
Deleted,
Expand Down
2 changes: 2 additions & 0 deletions packages/schema/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Schema } from "effect"
import { Agent } from "./agent.js"
import { Location } from "./location.js"
import { Model } from "./model.js"
import { Permission } from "./permission.js"
import { Project } from "./project.js"
import { DateTimeUtcFromMillis, optional, RelativePath } from "./schema.js"
import { SessionEvent } from "./session-event.js"
Expand Down Expand Up @@ -46,6 +47,7 @@ export const Info = Schema.Struct({
location: Location.Ref,
subpath: RelativePath.pipe(optional),
revert: Revert.State.pipe(optional),
tools: Permission.Ruleset.pipe(optional),
}).annotate({ identifier: "SessionV2.Info" })

export const ListAnchor = Schema.Struct({
Expand Down
1 change: 1 addition & 0 deletions packages/schema/test/event-manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ describe("public event manifest", () => {
"message.part.removed.1",
"session.agent.selected.1",
"session.model.selected.1",
"session.tools.configured.1",
"session.moved.1",
"session.renamed.1",
"session.forked.1",
Expand Down
16 changes: 16 additions & 0 deletions packages/server/src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,22 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.configure",
Effect.fn(function* (ctx) {
yield* session.configure({ sessionID: ctx.params.sessionID, tools: ctx.payload.tools }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.rename",
Effect.fn(function* (ctx) {
Expand Down
Loading