Compare commits

...

10 Commits

Author SHA1 Message Date
Kit Langton 94bd58f088 fix(question): keep experimental api local 2026-04-14 22:25:31 -04:00
Kit Langton 9d8f8ddcee refactor(permission): make experimental server layer-first
Expose the experimental permission and question HttpApi server as Effect layers and test it through NodeHttpServer.layerTest and HttpClient instead of an imperative listen API.
2026-04-14 22:22:19 -04:00
Kit Langton a8691964b3 refactor(permission): use HttpApi basic auth middleware
Keep only the auth_token normalization as custom router middleware and move the actual credential enforcement onto HttpApiSecurity.basic so the parallel experimental server follows the Effect-native auth pattern.
2026-04-14 22:22:19 -04:00
Kit Langton ec1946c057 refactor(permission): decode middleware inputs with schemas
Replace the ad hoc URL and header parsing in the experimental Effect-native HttpApi server with HttpServerRequest schema decoders so the auth and instance middleware stay schema-driven and easier to reason about.
2026-04-14 22:22:19 -04:00
Kit Langton 4e841cfd58 refactor(permission): keep effect server internal
Keep the experimental Effect-native HttpApi server as an internal implementation detail for now instead of exposing it through the CLI serve command or a public env flag.
2026-04-14 22:22:19 -04:00
Kit Langton 055be86616 refactor(permission): serve experimental httpapi with effect
Move the permission slice onto the parallel Effect-native experimental HttpApi server, stop extending the Hono mount path for this PR, and keep the question and permission groups served through the direct Effect server boundary.
2026-04-14 22:22:11 -04:00
Kit Langton e7725ff575 refactor(permission): use Schema.Literals 2026-04-14 22:21:02 -04:00
Kit Langton 168e4a4e27 refactor(permission): use effectful HttpApi group builder
Build the permission HttpApi handlers with an effectful group callback so the permission service is resolved once at layer construction time and the endpoint handlers close over the resulting service methods.
2026-04-14 22:21:02 -04:00
Kit Langton 97b0f211b5 refactor(permission): reuse canonical reply fields
Share the permission reply field definition between the transport body and service input schemas and align the list contract with the readonly response style used by the question slice.
2026-04-14 22:21:02 -04:00
Kit Langton ec25363a5a add experimental permission HttpApi slice
Move the shared permission route DTOs to Effect Schema, mount a parallel permission HttpApi surface, and cover the new list/reply/docs flow with a server test.
2026-04-14 22:21:02 -04:00
9 changed files with 514 additions and 82 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ export namespace Agent {
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
permission: Permission.Ruleset,
permission: Permission.Ruleset.zod,
model: z
.object({
modelID: ModelID.zod,
+80 -62
View File
@@ -7,75 +7,86 @@ import { Instance } from "@/project/instance"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db"
import { zod } from "@/util/effect-zod"
import { Log } from "@/util/log"
import { withStatics } from "@/util/schema"
import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import os from "os"
import z from "zod"
import { evaluate as evalRule } from "./evaluate"
import { PermissionID } from "./schema"
export namespace Permission {
const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({
ref: "PermissionAction",
})
export type Action = z.infer<typeof Action>
export const Action = Schema.Literals(["allow", "deny", "ask"])
.annotate({ identifier: "PermissionAction" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Action = Schema.Schema.Type<typeof Action>
export const Rule = z
.object({
permission: z.string(),
pattern: z.string(),
action: Action,
})
.meta({
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export class Rule extends Schema.Class<Rule>("PermissionRule")({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}) {
static readonly zod = zod(this)
}
export const Ruleset = Rule.array().meta({
ref: "PermissionRuleset",
})
export type Ruleset = z.infer<typeof Ruleset>
export const Ruleset = Schema.mutable(Schema.Array(Rule))
.annotate({ identifier: "PermissionRuleset" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
export const Request = z
.object({
id: PermissionID.zod,
sessionID: SessionID.zod,
permission: z.string(),
patterns: z.string().array(),
metadata: z.record(z.string(), z.any()),
always: z.string().array(),
tool: z
.object({
messageID: MessageID.zod,
callID: z.string(),
})
.optional(),
})
.meta({
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export class Tool extends Schema.Class<Tool>("PermissionTool")({
messageID: MessageID,
callID: Schema.String,
}) {
static readonly zod = zod(this)
}
export const Reply = z.enum(["once", "always", "reject"])
export type Reply = z.infer<typeof Reply>
export class Request extends Schema.Class<Request>("PermissionRequest")({
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(Tool),
}) {
static readonly zod = zod(this)
}
export const Approval = z.object({
projectID: ProjectID.zod,
patterns: z.string().array(),
})
export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
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" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
export class Approval extends Schema.Class<Approval>("PermissionApproval")({
projectID: ProjectID,
patterns: Schema.Array(Schema.String),
}) {
static readonly zod = zod(this)
}
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Asked: BusEvent.define("permission.asked", Request.zod),
Replied: BusEvent.define(
"permission.replied",
z.object({
sessionID: SessionID.zod,
requestID: PermissionID.zod,
reply: Reply,
}),
zod(
Schema.Struct({
sessionID: SessionID,
requestID: PermissionID,
reply: Reply,
}).annotate({ identifier: "PermissionReplied" }),
),
),
}
@@ -103,20 +114,27 @@ export namespace Permission {
export type Error = DeniedError | RejectedError | CorrectedError
export const AskInput = Request.partial({ id: true }).extend({
export const AskInput = Schema.Struct({
...Request.fields,
id: Schema.optional(PermissionID),
ruleset: Ruleset,
})
.annotate({ identifier: "PermissionAskInput" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type AskInput = Schema.Schema.Type<typeof AskInput>
export const ReplyInput = z.object({
requestID: PermissionID.zod,
reply: Reply,
message: z.string().optional(),
export const ReplyInput = Schema.Struct({
requestID: PermissionID,
...reply,
})
.annotate({ identifier: "PermissionReplyInput" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
export interface Interface {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]>
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
readonly reply: (input: ReplyInput) => Effect.Effect<void>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
}
interface PendingEntry {
@@ -163,7 +181,7 @@ export namespace Permission {
}),
)
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
const { approved, pending } = yield* InstanceState.get(state)
const { ruleset, ...request } = input
let needsAsk = false
@@ -183,10 +201,10 @@ export namespace Permission {
if (!needsAsk) return
const id = request.id ?? PermissionID.ascending()
const info: Request = {
const info = Schema.decodeUnknownSync(Request)({
id,
...request,
}
})
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
@@ -200,7 +218,7 @@ export namespace Permission {
)
})
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
const { approved, pending } = yield* InstanceState.get(state)
const existing = pending.get(input.requestID)
if (!existing) return
@@ -0,0 +1,95 @@
import { AppLayer } from "@/effect/app-runtime"
import { memoMap } from "@/effect/run-service"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { lazy } from "@/util/lazy"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import type { Handler } from "hono"
const root = "/experimental/httpapi/permission"
export const PermissionApi = HttpApi.make("permission")
.add(
HttpApiGroup.make("permission")
.add(
HttpApiEndpoint.get("list", root, {
success: Schema.Array(Permission.Request),
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.list",
summary: "List pending permissions",
description: "Get all pending permission requests across all sessions.",
}),
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: PermissionID },
payload: Permission.ReplyBody,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "permission.reply",
summary: "Respond to permission request",
description: "Approve or deny a permission request from the AI assistant.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "permission",
description: "Experimental HttpApi permission routes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const PermissionLive = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* Permission.Service
const list = Effect.fn("PermissionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: {
params: { requestID: PermissionID }
payload: Permission.ReplyBody
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
reply: ctx.payload.reply,
message: ctx.payload.message,
})
return true
})
return HttpApiBuilder.group(PermissionApi, "permission", (handlers) =>
handlers.handle("list", list).handle("reply", reply),
)
}),
).pipe(Layer.provide(Permission.defaultLayer))
const web = lazy(() =>
HttpRouter.toWebHandler(
Layer.mergeAll(
AppLayer,
HttpApiBuilder.layer(PermissionApi, { openapiPath: `${root}/doc` }).pipe(
Layer.provide(PermissionLive),
Layer.provide(HttpServer.layerServices),
),
),
{
disableLogger: true,
memoMap,
},
),
)
export const PermissionHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw)
@@ -3,33 +3,83 @@ import { memoMap } from "@/effect/run-service"
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { lazy } from "@/util/lazy"
import { makeQuestionHandler, questionApi } from "@opencode-ai/server"
import { Effect, Layer } from "effect"
import { Effect, Layer, Schema } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import type { Handler } from "hono"
const root = "/experimental/httpapi/question"
const QuestionLive = makeQuestionHandler({
list: Effect.fn("QuestionHttpApi.host.list")(function* () {
export const QuestionApi = HttpApi.make("question")
.add(
HttpApiGroup.make("question")
.add(
HttpApiEndpoint.get("list", root, {
success: Schema.Array(Question.Request),
}).annotateMerge(
OpenApi.annotations({
identifier: "question.list",
summary: "List pending questions",
description: "Get all pending question requests across all sessions.",
}),
),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
params: { requestID: QuestionID },
payload: Question.Reply,
success: Schema.Boolean,
}).annotateMerge(
OpenApi.annotations({
identifier: "question.reply",
summary: "Reply to question request",
description: "Provide answers to a question request from the AI assistant.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "question",
description: "Experimental HttpApi question routes.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",
version: "0.0.1",
description: "Experimental HttpApi surface for selected instance routes.",
}),
)
export const QuestionLive = Layer.unwrap(
Effect.gen(function* () {
const svc = yield* Question.Service
return yield* svc.list()
}),
reply: Effect.fn("QuestionHttpApi.host.reply")(function* (input) {
const svc = yield* Question.Service
yield* svc.reply({
requestID: QuestionID.make(input.requestID),
answers: input.answers,
const list = Effect.fn("QuestionHttpApi.list")(function* () {
return yield* svc.list()
})
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
params: { requestID: QuestionID }
payload: Question.Reply
}) {
yield* svc.reply({
requestID: ctx.params.requestID,
answers: ctx.payload.answers,
})
return true
})
return HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
handlers.handle("list", list).handle("reply", reply),
)
}),
}).pipe(Layer.provide(Question.defaultLayer))
).pipe(Layer.provide(Question.defaultLayer))
const web = lazy(() =>
HttpRouter.toWebHandler(
Layer.mergeAll(
AppLayer,
HttpApiBuilder.layer(questionApi, { openapiPath: `${root}/doc` }).pipe(
HttpApiBuilder.layer(QuestionApi, { openapiPath: `${root}/doc` }).pipe(
Layer.provide(QuestionLive),
Layer.provide(HttpServer.layerServices),
),
@@ -0,0 +1,135 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer, Redacted, Schema } from "effect"
import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createServer } from "node:http"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { Flag } from "@/flag/flag"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import { Filesystem } from "@/util/filesystem"
import { Permission } from "@/permission"
import { Question } from "@/question"
import { PermissionApi, PermissionLive } from "./permission"
import { QuestionApi, QuestionLive } from "./question"
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
auth_token: Schema.optional(Schema.String),
})
const Headers = Schema.Struct({
authorization: Schema.optional(Schema.String),
"x-opencode-directory": Schema.optional(Schema.String),
})
export namespace ExperimentalHttpApiServer {
function text(input: string, status: number, headers?: Record<string, string>) {
return HttpServerResponse.text(input, { status, headers })
}
function decode(input: string) {
try {
return decodeURIComponent(input)
} catch {
return input
}
}
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/ExperimentalHttpApiAuthorization", {
error: Unauthorized,
security: {
basic: HttpApiSecurity.basic,
},
}) {}
const normalize = HttpRouter.middleware()(
Effect.gen(function* () {
return (effect) =>
Effect.gen(function* () {
const query = yield* HttpServerRequest.schemaSearchParams(Query)
if (!query.auth_token) return yield* effect
const req = yield* HttpServerRequest.HttpServerRequest
const next = req.modify({
headers: {
...req.headers,
authorization: `Basic ${query.auth_token}`,
},
})
return yield* effect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, next))
})
}),
).layer
const auth = Layer.succeed(
Authorization,
Authorization.of({
basic: (effect, { credential }) =>
Effect.gen(function* () {
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
const user = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
if (credential.username !== user) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
return yield* effect
}),
}),
)
const instance = HttpRouter.middleware()(
Effect.gen(function* () {
return (effect) =>
Effect.gen(function* () {
const query = yield* HttpServerRequest.schemaSearchParams(Query)
const headers = yield* HttpServerRequest.schemaHeaders(Headers)
const raw = query.directory || headers["x-opencode-directory"] || process.cwd()
const workspace = query.workspace || undefined
const ctx = yield* Effect.promise(() =>
Instance.provide({
directory: Filesystem.resolve(decode(raw)),
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect
return yield* next.pipe(Effect.provideService(InstanceRef, ctx))
})
}),
).layer
const QuestionSecured = QuestionApi.middleware(Authorization)
const PermissionSecured = PermissionApi.middleware(Authorization)
export const routes = Layer.mergeAll(
HttpApiBuilder.layer(QuestionSecured, { openapiPath: "/experimental/httpapi/question/doc" }).pipe(
Layer.provide(QuestionLive),
),
HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe(
Layer.provide(PermissionLive),
),
).pipe(Layer.provide(auth), Layer.provide(normalize), Layer.provide(instance))
export const layer = (opts: { hostname: string; port: number }) =>
HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(createServer, { port: opts.port, host: opts.hostname })),
)
export const layerTest = HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(Question.defaultLayer),
Layer.provideMerge(Permission.defaultLayer),
)
}
@@ -33,7 +33,7 @@ export const PermissionRoutes = lazy(() =>
requestID: PermissionID.zod,
}),
),
validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })),
validator("json", Permission.ReplyBody.zod),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
@@ -60,7 +60,7 @@ export const PermissionRoutes = lazy(() =>
description: "List of pending permissions",
content: {
"application/json": {
schema: resolver(Permission.Request.array()),
schema: resolver(z.array(Permission.Request.zod)),
},
},
},
@@ -274,7 +274,7 @@ export const SessionRoutes = lazy(() =>
"json",
z.object({
title: z.string().optional(),
permission: Permission.Ruleset.optional(),
permission: Permission.Ruleset.zod.optional(),
time: z
.object({
archived: z.number().optional(),
@@ -1093,7 +1093,7 @@ export const SessionRoutes = lazy(() =>
permissionID: PermissionID.zod,
}),
),
validator("json", z.object({ response: Permission.Reply })),
validator("json", z.object({ response: Permission.Reply.zod })),
async (c) => {
const params = c.req.valid("param")
await AppRuntime.runPromise(
+44 -1
View File
@@ -14,7 +14,9 @@ import type { SQL } from "../storage/db"
import { PartTable, SessionTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage"
import { makeRuntime } from "@/effect/run-service"
import { Log } from "../util/log"
import { fn } from "../util/fn"
import { updateSchema } from "../util/update-schema"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
@@ -144,7 +146,7 @@ export namespace Session {
compacting: z.number().optional(),
archived: z.number().optional(),
}),
permission: Permission.Ruleset.optional(),
permission: Permission.Ruleset.zod.optional(),
revert: z
.object({
messageID: MessageID.zod,
@@ -704,6 +706,47 @@ export namespace Session {
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export const create = fn(
z
.object({
parentID: SessionID.zod.optional(),
title: z.string().optional(),
permission: Info.shape.permission,
workspaceID: WorkspaceID.zod.optional(),
})
.optional(),
(input) => runPromise((svc) => svc.create(input)),
)
export const fork = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }), (input) =>
runPromise((svc) => svc.fork(input)),
)
export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id)))
export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) =>
runPromise((svc) => svc.setTitle(input)),
)
export const setArchived = fn(z.object({ sessionID: SessionID.zod, time: z.number().optional() }), (input) =>
runPromise((svc) => svc.setArchived(input)),
)
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset.zod }), (input) =>
runPromise((svc) => svc.setPermission(input)),
)
export const setRevert = fn(
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
(input) =>
runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })),
)
export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) =>
runPromise((svc) => svc.messages(input)),
)
export function* list(input?: {
directory?: string
workspaceID?: WorkspaceID
@@ -0,0 +1,91 @@
import { describe, expect } from "bun:test"
import { AppRuntime } from "../../src/effect/app-runtime"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Permission } from "../../src/permission"
import { Instance } from "../../src/project/instance"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { ExperimentalHttpApiServer } from "../../src/server/instance/httpapi/server"
import { SessionID } from "../../src/session/schema"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { Effect, Fiber } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
Log.init({ print: false })
const it = testEffect(ExperimentalHttpApiServer.layerTest)
describe("experimental permission httpapi", () => {
it.live("lists pending permissions, replies, and serves docs", () =>
Effect.gen(function* () {
const tmp = yield* Effect.promise(() => tmpdir({ git: true }))
yield* Effect.addFinalizer(() => Effect.promise(() => tmp[Symbol.asyncDispose]()))
yield* Effect.addFinalizer(() => Effect.promise(() => Instance.disposeAll()))
const headers = {
"content-type": "application/json",
"x-opencode-directory": tmp.path,
}
const ctx = yield* Effect.promise(() =>
Instance.provide({
directory: tmp.path,
init: () => AppRuntime.runPromise(InstanceBootstrap),
fn: () => Instance.current,
}),
)
const svc = yield* Permission.Service
const pending = yield* svc
.ask({
sessionID: SessionID.make("ses_test"),
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
ruleset: [],
})
.pipe(Effect.provideService(InstanceRef, ctx), Effect.forkScoped)
let items: Array<any> = []
for (let i = 0; i < 10; i++) {
const list = yield* HttpClient.execute(
HttpClientRequest.get("/experimental/httpapi/permission").pipe(HttpClientRequest.setHeaders(headers)),
)
expect(list.status).toBe(200)
items = (yield* list.json) as Array<any>
if (items.length > 0) break
yield* Effect.sleep("50 millis")
}
expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({
permission: "bash",
patterns: ["ls"],
metadata: { cmd: "ls" },
always: ["ls"],
})
const doc = yield* HttpClient.execute(
HttpClientRequest.get("/experimental/httpapi/permission/doc").pipe(HttpClientRequest.setHeaders(headers)),
)
expect(doc.status).toBe(200)
const spec = (yield* doc.json) as any
expect(spec.paths["/experimental/httpapi/permission"]?.get?.operationId).toBe("permission.list")
expect(spec.paths["/experimental/httpapi/permission/{requestID}/reply"]?.post?.operationId).toBe(
"permission.reply",
)
const reply = yield* HttpClient.execute(
yield* HttpClientRequest.post(`/experimental/httpapi/permission/${items[0].id}/reply`).pipe(
HttpClientRequest.setHeaders(headers),
HttpClientRequest.bodyJson({ reply: "once" }),
),
)
expect(reply.status).toBe(200)
expect(yield* reply.json).toBe(true)
yield* Fiber.join(pending)
}),
)
})