Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c616b3252 | ||
|
|
8a5d19c376 | ||
|
|
b8ca71d309 |
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Permission } from "../permission"
|
||||||
|
import type { Agent } from "./agent"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `permission` ruleset for a subagent's session when it's spawned
|
||||||
|
* via the task tool. Combines:
|
||||||
|
*
|
||||||
|
* 1. The parent **agent's** deny rules — Plan Mode and other agent-level
|
||||||
|
* restrictions live on the agent ruleset, not on the session, so a
|
||||||
|
* subagent that only inherited the parent SESSION's permission would
|
||||||
|
* silently bypass them. (#26514)
|
||||||
|
* 2. The parent **session's** deny rules and external_directory rules —
|
||||||
|
* same forwarding the original code already did.
|
||||||
|
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
|
||||||
|
* doesn't already permit them.
|
||||||
|
*/
|
||||||
|
export function deriveSubagentSessionPermission(input: {
|
||||||
|
parentSessionPermission: Permission.Ruleset
|
||||||
|
parentAgent: Agent.Info | undefined
|
||||||
|
subagent: Agent.Info
|
||||||
|
}): Permission.Ruleset {
|
||||||
|
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
|
||||||
|
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
|
||||||
|
const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
|
||||||
|
return [
|
||||||
|
...parentAgentDenies,
|
||||||
|
...input.parentSessionPermission.filter(
|
||||||
|
(rule) => rule.permission === "external_directory" || rule.action === "deny",
|
||||||
|
),
|
||||||
|
...(canTodo ? [] : [{ permission: "todowrite" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||||
|
...(canTask ? [] : [{ permission: "task" as const, pattern: "*" as const, action: "deny" as const }]),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Session } from "@/session/session"
|
|||||||
import { SessionID, MessageID } from "../session/schema"
|
import { SessionID, MessageID } from "../session/schema"
|
||||||
import { MessageV2 } from "../session/message-v2"
|
import { MessageV2 } from "../session/message-v2"
|
||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
|
import { deriveSubagentSessionPermission } from "../agent/subagent-permissions"
|
||||||
import type { SessionPrompt } from "../session/prompt"
|
import type { SessionPrompt } from "../session/prompt"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Effect, Exit, Schema } from "effect"
|
import { Effect, Exit, Schema } from "effect"
|
||||||
@@ -58,41 +59,25 @@ export const TaskTool = Tool.define(
|
|||||||
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
|
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
|
||||||
}
|
}
|
||||||
|
|
||||||
const canTask = next.permission.some((rule) => rule.permission === id)
|
|
||||||
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
|
|
||||||
|
|
||||||
const taskID = params.task_id
|
const taskID = params.task_id
|
||||||
const session = taskID
|
const session = taskID
|
||||||
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||||
: undefined
|
: undefined
|
||||||
const parent = yield* sessions.get(ctx.sessionID)
|
const parent = yield* sessions.get(ctx.sessionID)
|
||||||
|
const parentAgent = parent.agent
|
||||||
|
? yield* agent.get(parent.agent).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||||
|
: undefined
|
||||||
const nextSession =
|
const nextSession =
|
||||||
session ??
|
session ??
|
||||||
(yield* sessions.create({
|
(yield* sessions.create({
|
||||||
parentID: ctx.sessionID,
|
parentID: ctx.sessionID,
|
||||||
title: params.description + ` (@${next.name} subagent)`,
|
title: params.description + ` (@${next.name} subagent)`,
|
||||||
permission: [
|
permission: [
|
||||||
...(parent.permission ?? []).filter(
|
...deriveSubagentSessionPermission({
|
||||||
(rule) => rule.permission === "external_directory" || rule.action === "deny",
|
parentSessionPermission: parent.permission ?? [],
|
||||||
),
|
parentAgent,
|
||||||
...(canTodo
|
subagent: next,
|
||||||
? []
|
}),
|
||||||
: [
|
|
||||||
{
|
|
||||||
permission: "todowrite" as const,
|
|
||||||
pattern: "*" as const,
|
|
||||||
action: "deny" as const,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
...(canTask
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
permission: id,
|
|
||||||
pattern: "*" as const,
|
|
||||||
action: "deny" as const,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
...(cfg.experimental?.primary_tools?.map((item) => ({
|
...(cfg.experimental?.primary_tools?.map((item) => ({
|
||||||
pattern: "*",
|
pattern: "*",
|
||||||
action: "allow" as const,
|
action: "allow" as const,
|
||||||
@@ -144,8 +129,8 @@ export const TaskTool = Tool.define(
|
|||||||
},
|
},
|
||||||
agent: next.name,
|
agent: next.name,
|
||||||
tools: {
|
tools: {
|
||||||
...(canTodo ? {} : { todowrite: false }),
|
...(next.permission.some((rule) => rule.permission === "todowrite") ? {} : { todowrite: false }),
|
||||||
...(canTask ? {} : { task: false }),
|
...(next.permission.some((rule) => rule.permission === id) ? {} : { task: false }),
|
||||||
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
|
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
|
||||||
},
|
},
|
||||||
parts,
|
parts,
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* Reproducer for opencode issue #26514:
|
||||||
|
*
|
||||||
|
* In Plan Mode (the `plan` agent), the main agent's edit/write tools are
|
||||||
|
* blocked by the plan agent's permission ruleset (`edit: { "*": "deny" }`).
|
||||||
|
* However, when the plan agent spawns a subagent via the `task` tool, the
|
||||||
|
* subagent retains full file modification capabilities — a security bypass.
|
||||||
|
*
|
||||||
|
* This test replicates the permission ruleset that would govern a
|
||||||
|
* `general` subagent when launched from a `plan` parent session, mirroring
|
||||||
|
* the logic in `src/tool/task.ts` (filtered parent permissions ++ runtime
|
||||||
|
* subagent agent permissions, evaluated as in `session/prompt.ts`).
|
||||||
|
*
|
||||||
|
* The expected (secure) behavior is that the subagent inherits the plan
|
||||||
|
* mode read-only restriction and `edit`/`write` resolve to `deny`. On
|
||||||
|
* origin/dev this assertion fails because the parent **agent** permissions
|
||||||
|
* are not propagated to the subagent — only the parent **session**
|
||||||
|
* permissions are passed through, and Plan Mode's restrictions live on the
|
||||||
|
* agent, not the session.
|
||||||
|
*/
|
||||||
|
import { test, expect, afterEach } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
|
||||||
|
import { WithInstance } from "../../src/project/with-instance"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permissions"
|
||||||
|
import { Permission } from "../../src/permission"
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await disposeAllInstances()
|
||||||
|
})
|
||||||
|
|
||||||
|
function load<A>(dir: string, fn: (svc: Agent.Interface) => Effect.Effect<A>) {
|
||||||
|
return Effect.runPromise(provideInstance(dir)(Agent.Service.use(fn)).pipe(Effect.provide(Agent.defaultLayer)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// `deriveSubagentSessionPermission` is imported from production. The test
|
||||||
|
// exercises the actual helper that task.ts uses to build the subagent's
|
||||||
|
// session permission, so any regression in that helper trips this test.
|
||||||
|
|
||||||
|
test("[#26514] subagent spawned from plan mode inherits read-only restriction (edit denied)", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
await WithInstance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
|
||||||
|
const generalAgent = await load(tmp.path, (svc) => svc.get("general"))
|
||||||
|
|
||||||
|
expect(planAgent).toBeDefined()
|
||||||
|
expect(generalAgent).toBeDefined()
|
||||||
|
// Sanity: the plan agent itself blocks edit. (Note: `write` and
|
||||||
|
// `apply_patch` route through the `edit` permission at the runtime
|
||||||
|
// tool layer — see Permission.disabled / EDIT_TOOLS.)
|
||||||
|
expect(Permission.evaluate("edit", "/some/file.ts", planAgent!.permission).action).toBe("deny")
|
||||||
|
|
||||||
|
// Simulate the plan-mode parent session: in real flow the plan
|
||||||
|
// 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 subagentSessionPermission = deriveSubagentSessionPermission({
|
||||||
|
parentSessionPermission,
|
||||||
|
parentAgent: planAgent,
|
||||||
|
subagent: generalAgent!,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mirror the runtime evaluation in session/prompt.ts (~line 410, 639):
|
||||||
|
// ruleset: Permission.merge(agent.permission, session.permission ?? [])
|
||||||
|
const effective = Permission.merge(generalAgent!.permission, subagentSessionPermission)
|
||||||
|
|
||||||
|
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
|
||||||
|
expect(Permission.evaluate("edit", "/another/path/index.tsx", effective).action).toBe("deny")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("[#26514] explore subagent launched from plan mode also stays read-only", async () => {
|
||||||
|
// Sibling check: even though `explore` is intrinsically read-only, the
|
||||||
|
// bug surface is the same. Including this case to document that the fix
|
||||||
|
// should propagate the parent **agent** permissions, not just deny edit
|
||||||
|
// when the subagent happens to already deny it.
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
await WithInstance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
|
||||||
|
const explore = await load(tmp.path, (svc) => svc.get("explore"))
|
||||||
|
expect(planAgent).toBeDefined()
|
||||||
|
expect(explore).toBeDefined()
|
||||||
|
|
||||||
|
const parentSessionPermission: Permission.Ruleset = []
|
||||||
|
const subagentSessionPermission = deriveSubagentSessionPermission({
|
||||||
|
parentSessionPermission,
|
||||||
|
parentAgent: planAgent,
|
||||||
|
subagent: explore!,
|
||||||
|
})
|
||||||
|
const effective = Permission.merge(explore!.permission, subagentSessionPermission)
|
||||||
|
|
||||||
|
// Already deny — sanity check.
|
||||||
|
expect(Permission.evaluate("edit", "/x.ts", effective).action).toBe("deny")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("[#26514] custom user subagent launched from plan mode bypasses Plan Mode read-only", async () => {
|
||||||
|
// The most damaging case: a user-defined subagent with default
|
||||||
|
// permissions (allow-by-default, like `general`). The subagent must NOT
|
||||||
|
// be able to edit when the parent agent is `plan`.
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
config: {
|
||||||
|
agent: {
|
||||||
|
my_subagent: {
|
||||||
|
description: "A user-defined subagent",
|
||||||
|
mode: "subagent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await WithInstance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const planAgent = await load(tmp.path, (svc) => svc.get("plan"))
|
||||||
|
const my = await load(tmp.path, (svc) => svc.get("my_subagent"))
|
||||||
|
expect(planAgent).toBeDefined()
|
||||||
|
expect(my).toBeDefined()
|
||||||
|
|
||||||
|
const parentSessionPermission: Permission.Ruleset = []
|
||||||
|
const subagentSessionPermission = deriveSubagentSessionPermission({
|
||||||
|
parentSessionPermission,
|
||||||
|
parentAgent: planAgent,
|
||||||
|
subagent: my!,
|
||||||
|
})
|
||||||
|
const effective = Permission.merge(my!.permission, subagentSessionPermission)
|
||||||
|
|
||||||
|
// BUG: on origin/dev edit resolves to "allow" because the plan
|
||||||
|
// agent's `edit: deny *` rule never reaches the subagent.
|
||||||
|
expect(Permission.evaluate("edit", "/some/file.ts", effective).action).toBe("deny")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ConfigProvider, Effect, Layer } from "effect"
|
import { ConfigProvider, Effect, Layer } from "effect"
|
||||||
import { HttpRouter } from "effect/unstable/http"
|
import { HttpRouter } from "effect/unstable/http"
|
||||||
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { parse } from "./assertions"
|
import { parse } from "./assertions"
|
||||||
import { runtime, type Runtime } from "./runtime"
|
import { runtime, type Runtime } from "./runtime"
|
||||||
import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types"
|
import type { ActiveScenario, Backend, BackendApp, CallResult, CaptureMode, SeededContext } from "./types"
|
||||||
@@ -17,9 +18,47 @@ export function call(
|
|||||||
ctx: SeededContext<unknown>,
|
ctx: SeededContext<unknown>,
|
||||||
options: CallOptions = {},
|
options: CallOptions = {},
|
||||||
) {
|
) {
|
||||||
return Effect.promise(async () =>
|
return Effect.promise(async () => {
|
||||||
capture(await app(await runtime(), backend, options).request(toRequest(scenario, ctx)), scenario.capture),
|
const handler = app(await runtime(), backend, options)
|
||||||
)
|
if (scenario.sdkCall) return callViaSdk(handler, scenario, ctx)
|
||||||
|
return capture(await handler.request(toRequest(scenario, ctx)), scenario.capture)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the scenario through a real `createOpencodeClient` wired to the
|
||||||
|
* in-process exerciser router. The SDK applies its real request transforms
|
||||||
|
* (auto-injected `?directory=...` / `?workspace=...` on GETs, header
|
||||||
|
* rewrites, etc.), so any drift between what the SDK sends and what the
|
||||||
|
* server's typed query schemas accept fails the scenario at write time.
|
||||||
|
*/
|
||||||
|
async function callViaSdk(handler: BackendApp, scenario: ActiveScenario, ctx: SeededContext<unknown>) {
|
||||||
|
const sdk = createOpencodeClient({
|
||||||
|
baseUrl: "http://localhost",
|
||||||
|
directory: ctx.directory,
|
||||||
|
fetch: ((input: Request | URL | string, init?: RequestInit) => handler.request(input, init)) as unknown as typeof fetch,
|
||||||
|
})
|
||||||
|
let result: unknown
|
||||||
|
let thrown: unknown
|
||||||
|
try {
|
||||||
|
result = await scenario.sdkCall!(sdk, ctx)
|
||||||
|
} catch (err) {
|
||||||
|
thrown = err
|
||||||
|
}
|
||||||
|
return normalizeSdkResult(result, thrown)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSdkResult(result: unknown, thrown: unknown): CallResult {
|
||||||
|
// SDK returns either { data, error, response } when not throwing, or
|
||||||
|
// throws an Error with `.cause = { body, status }` when throwOnError: true.
|
||||||
|
const tuple = result as { data?: unknown; error?: unknown; response?: Response } | undefined
|
||||||
|
const cause = (thrown as { cause?: { status?: number; body?: unknown } } | undefined)?.cause
|
||||||
|
const response = tuple?.response
|
||||||
|
const status = response?.status ?? cause?.status ?? (thrown ? 0 : 200)
|
||||||
|
const contentType = response?.headers.get("content-type") ?? "application/json"
|
||||||
|
const body = tuple?.data ?? tuple?.error ?? cause?.body ?? thrown
|
||||||
|
const text = typeof body === "string" ? body : JSON.stringify(body ?? null)
|
||||||
|
return { status, contentType, body, text, timedOut: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function callAuthProbe(
|
export function callAuthProbe(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
ProjectOptions,
|
ProjectOptions,
|
||||||
RequestSpec,
|
RequestSpec,
|
||||||
ScenarioContext,
|
ScenarioContext,
|
||||||
|
Sdk,
|
||||||
SeededContext,
|
SeededContext,
|
||||||
TodoScenario,
|
TodoScenario,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
@@ -50,6 +51,22 @@ class ScenarioBuilder<S = undefined> {
|
|||||||
return this.clone({ request })
|
return this.clone({ request })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the scenario through the real SDK client wired to the in-process
|
||||||
|
* exerciser router. The SDK applies its real request transforms (for example,
|
||||||
|
* auto-injecting `?directory=...` on GETs when a directory is set) so route
|
||||||
|
* tests catch the SDK-vs-server-shape drift class at write time instead of
|
||||||
|
* regression time. Existing `.at(...)` scenarios are unchanged.
|
||||||
|
*
|
||||||
|
* The callback may return either an SDK result tuple (`{ data, error,
|
||||||
|
* response }`) or throw (use `{ throwOnError: true }`); the runner
|
||||||
|
* normalizes both into the same `CallResult` shape that `.json()` /
|
||||||
|
* `.status()` / `.ok()` already understand.
|
||||||
|
*/
|
||||||
|
viaSdk(sdkCall: (sdk: Sdk, ctx: SeededContext<S>) => Promise<unknown>) {
|
||||||
|
return this.clone({ sdkCall })
|
||||||
|
}
|
||||||
|
|
||||||
probe(authProbe: RequestSpec) {
|
probe(authProbe: RequestSpec) {
|
||||||
return this.clone({ authProbe })
|
return this.clone({ authProbe })
|
||||||
}
|
}
|
||||||
@@ -167,6 +184,8 @@ class ScenarioBuilder<S = undefined> {
|
|||||||
mutates: state.mutates,
|
mutates: state.mutates,
|
||||||
reset: state.reset,
|
reset: state.reset,
|
||||||
auth: state.auth,
|
auth: state.auth,
|
||||||
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired sdkCall/state type inside the builder.
|
||||||
|
sdkCall: state.sdkCall as ActiveScenario["sdkCall"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,19 @@ const scenarios: Scenario[] = [
|
|||||||
http.protected.get("/command", "command.list").json(200, array, "status"),
|
http.protected.get("/command", "command.list").json(200, array, "status"),
|
||||||
http.protected.get("/agent", "app.agents").json(200, array, "status"),
|
http.protected.get("/agent", "app.agents").json(200, array, "status"),
|
||||||
http.protected.get("/skill", "app.skills").json(200, array, "status"),
|
http.protected.get("/skill", "app.skills").json(200, array, "status"),
|
||||||
|
// Same /agent route exercised through the real SDK client. Catches the
|
||||||
|
// SDK-injection class of regressions (`?directory=...` auto-added on GETs)
|
||||||
|
// that the direct-Request path is structurally blind to. See #26569.
|
||||||
|
http.protected
|
||||||
|
.get("/agent", "app.agents.via_sdk")
|
||||||
|
.viaSdk((sdk) => sdk.app.agents({}, { throwOnError: true }))
|
||||||
|
.json(200, array, "status"),
|
||||||
|
// Same /command route via SDK — second proof that the directory injection
|
||||||
|
// works for any GET under workspace routing.
|
||||||
|
http.protected
|
||||||
|
.get("/command", "command.list.via_sdk")
|
||||||
|
.viaSdk((sdk) => sdk.command.list({}, { throwOnError: true }))
|
||||||
|
.json(200, array, "status"),
|
||||||
http.protected.get("/lsp", "lsp.status").json(200, array),
|
http.protected.get("/lsp", "lsp.status").json(200, array),
|
||||||
http.protected.get("/formatter", "formatter.status").json(200, array),
|
http.protected.get("/formatter", "formatter.status").json(200, array),
|
||||||
http.protected.get("/config", "config.get").json(200, undefined, "status"),
|
http.protected.get("/config", "config.get").json(200, undefined, "status"),
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import type { Duration, Effect } from "effect"
|
import type { Duration, Effect } from "effect"
|
||||||
|
import type { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import type { Config } from "../../../src/config/config"
|
import type { Config } from "../../../src/config/config"
|
||||||
import type { Project } from "../../../src/project/project"
|
import type { Project } from "../../../src/project/project"
|
||||||
import type { Worktree } from "../../../src/worktree"
|
import type { Worktree } from "../../../src/worktree"
|
||||||
import type { MessageV2 } from "../../../src/session/message-v2"
|
import type { MessageV2 } from "../../../src/session/message-v2"
|
||||||
import type { SessionID } from "../../../src/session/schema"
|
import type { SessionID } from "../../../src/session/schema"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real generated SDK client used by every consumer (TUI, Desktop, plugins).
|
||||||
|
* Scenarios that opt into `.viaSdk(...)` get one of these wired to the in-process
|
||||||
|
* exerciser router so SDK request transforms (auto-injected `?directory=...`,
|
||||||
|
* header rewrites, etc.) are exercised against real handlers — that's what
|
||||||
|
* catches the #26569 family.
|
||||||
|
*/
|
||||||
|
export type Sdk = ReturnType<typeof createOpencodeClient>
|
||||||
|
|
||||||
export const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const
|
export const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const
|
||||||
export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const
|
export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const
|
||||||
|
|
||||||
@@ -88,6 +98,7 @@ export type ActiveScenario = {
|
|||||||
mutates: boolean
|
mutates: boolean
|
||||||
reset: boolean
|
reset: boolean
|
||||||
auth: AuthPolicy
|
auth: AuthPolicy
|
||||||
|
sdkCall?: (sdk: Sdk, ctx: SeededContext<unknown>) => Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BuilderState<S> = {
|
export type BuilderState<S> = {
|
||||||
@@ -102,6 +113,7 @@ export type BuilderState<S> = {
|
|||||||
mutates: boolean
|
mutates: boolean
|
||||||
reset: boolean
|
reset: boolean
|
||||||
auth: AuthPolicy
|
auth: AuthPolicy
|
||||||
|
sdkCall?: (sdk: Sdk, ctx: SeededContext<S>) => Promise<unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TodoScenario = {
|
export type TodoScenario = {
|
||||||
|
|||||||
Reference in New Issue
Block a user