mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-15 13:26:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7106c1628 |
@@ -8,6 +8,7 @@ import { Provider } from "./provider.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { State } from "./state.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { ProviderPolicy } from "./provider-policy.js"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: Provider.MutableInfo
|
||||
@@ -63,6 +64,7 @@ const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const policies = yield* ProviderPolicy.Service
|
||||
|
||||
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
|
||||
if (provider.activation === "disabled") return false
|
||||
@@ -146,11 +148,15 @@ const layer = Layer.effect(
|
||||
|
||||
provider: {
|
||||
get: Effect.fn("Catalog.provider.get")(function* (providerID) {
|
||||
if (!ProviderPolicy.allows(yield* policies.read(), providerID)) return
|
||||
return state.get().providers.get(providerID)?.provider
|
||||
}),
|
||||
|
||||
all: Effect.fn("Catalog.provider.all")(function* () {
|
||||
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
|
||||
const policy = yield* policies.read()
|
||||
return Array.fromIterable(state.get().providers.values())
|
||||
.map((record) => record.provider)
|
||||
.filter((provider) => ProviderPolicy.allows(policy, provider.id))
|
||||
}),
|
||||
|
||||
available: Effect.fn("Catalog.provider.available")(function* () {
|
||||
@@ -163,6 +169,7 @@ const layer = Layer.effect(
|
||||
|
||||
model: {
|
||||
get: Effect.fn("Catalog.model.get")(function* (providerID, modelID) {
|
||||
if (!ProviderPolicy.allows(yield* policies.read(), providerID)) return
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return
|
||||
const model = record.models.get(modelID)
|
||||
@@ -170,8 +177,10 @@ const layer = Layer.effect(
|
||||
}),
|
||||
|
||||
all: Effect.fn("Catalog.model.all")(function* () {
|
||||
const policy = yield* policies.read()
|
||||
return pipe(
|
||||
Array.fromIterable(state.get().providers.values()),
|
||||
Array.filter((record) => ProviderPolicy.allows(policy, record.provider.id)),
|
||||
Array.flatMap((record) => {
|
||||
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
|
||||
}),
|
||||
@@ -209,6 +218,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
|
||||
small: Effect.fn("Catalog.model.small")(function* (providerID) {
|
||||
if (!ProviderPolicy.allows(yield* policies.read(), providerID)) return
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return
|
||||
const models = pipe(
|
||||
@@ -237,4 +247,8 @@ const layer = Layer.effect(
|
||||
|
||||
const SMALL_MODEL_FAMILY_PRIORITY = ["gpt-luna", "gemini-flash-lite", "gemini-flash", "claude-haiku"]
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Integration.node] })
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, Integration.node, ProviderPolicy.node],
|
||||
})
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
export * as ConfigPolicyPlugin from "./policy.js"
|
||||
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Document } from "@opencode/schema/config"
|
||||
import { Effect } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Wildcard } from "../../util/wildcard.js"
|
||||
import { ConfigEntryObserver } from "./entry-observer.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.policy",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.catalog.reload())
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
// User-global policy takes priority over policy authored by a repository.
|
||||
const policies = loaded.entries
|
||||
.filter((entry): entry is Document => entry.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||
for (const record of catalog.provider.list()) {
|
||||
const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource))
|
||||
if (policy?.effect === "deny") catalog.provider.remove(record.provider.id)
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -72,6 +72,10 @@ export const layer = Layer.effect(
|
||||
|
||||
const text: Interface["text"] = (input) =>
|
||||
runText(input).pipe(
|
||||
Effect.catchTag(
|
||||
["ProviderPolicy.Unavailable", "ProviderPolicy.Denied"],
|
||||
(error) => new UnavailableError({ message: error.message }),
|
||||
),
|
||||
Effect.catchTag(
|
||||
"Integration.Authorization",
|
||||
() =>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Integration } from "./integration.js"
|
||||
import { Capabilities, ID, Info, Ref, VariantID } from "./model.js"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Provider } from "./provider.js"
|
||||
import { ProviderPolicy } from "./provider-policy.js"
|
||||
|
||||
export class VariantUnavailableError extends Schema.TaggedError<VariantUnavailableError>()(
|
||||
"SessionRunnerModel.VariantUnavailableError",
|
||||
@@ -66,6 +67,8 @@ export class UnsupportedCompactionError extends Schema.TaggedError<UnsupportedCo
|
||||
}
|
||||
|
||||
export type Error =
|
||||
| ProviderPolicy.Unavailable
|
||||
| ProviderPolicy.Denied
|
||||
| VariantUnavailableError
|
||||
| UnsupportedPackageError
|
||||
| UnresolvedProviderVariablesError
|
||||
@@ -291,10 +294,12 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const policies = yield* ProviderPolicy.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const npm = yield* Npm.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
|
||||
yield* policies.assert(selected.providerID)
|
||||
const provider = yield* catalog.provider.get(selected.providerID)
|
||||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
@@ -328,6 +333,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
return Service.of({
|
||||
resolve: Effect.fn("ModelResolver.resolve")(function* (requested) {
|
||||
yield* policies.assert(requested?.providerID)
|
||||
const selected = requested
|
||||
? yield* catalog.model.get(requested.providerID, requested.id)
|
||||
: yield* catalog.model
|
||||
@@ -394,5 +400,5 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
|
||||
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node, ProviderPolicy.node],
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
|
||||
import { ConfigLocationWatcherPlugin } from "../config/plugin/location-watcher.js"
|
||||
import { ConfigMcpPlugin } from "../config/plugin/mcp.js"
|
||||
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
|
||||
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
|
||||
import { ProviderPolicy } from "../provider-policy.js"
|
||||
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
|
||||
import { ConfigShellPlugin } from "../config/plugin/shell.js"
|
||||
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
|
||||
@@ -98,6 +98,7 @@ const services = [
|
||||
Agent.Service,
|
||||
AppProcess.Service,
|
||||
Catalog.Service,
|
||||
ProviderPolicy.Service,
|
||||
Command.Service,
|
||||
Config.Service,
|
||||
Credential.Service,
|
||||
@@ -147,6 +148,7 @@ export const requirements = LayerNode.group([
|
||||
Agent.node,
|
||||
AppProcess.node,
|
||||
Catalog.node,
|
||||
ProviderPolicy.node,
|
||||
Command.node,
|
||||
Config.node,
|
||||
Credential.node,
|
||||
@@ -241,7 +243,6 @@ const post = [
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
export const list = Effect.fn("PluginInternal.list")(function* () {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Provider } from "../../provider.js"
|
||||
import { WebSearch } from "../../websearch.js"
|
||||
import { ConfigProvider } from "@opencode/schema/config/provider"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
import { ProviderPolicy } from "../../provider-policy.js"
|
||||
|
||||
const defaultServer = "https://opencode.ai/console"
|
||||
const clientID = "opencode-cli"
|
||||
@@ -19,7 +21,27 @@ const RemoteResponse = Schema.Struct({
|
||||
websearch: Schema.Struct({
|
||||
providerID: WebSearch.ID,
|
||||
}).pipe(Schema.optional),
|
||||
experimental: Schema.Unknown,
|
||||
managedPolicy: Schema.Unknown,
|
||||
})
|
||||
const RemotePolicy = Schema.Struct({
|
||||
experimental: Schema.Struct({
|
||||
policies: Schema.Array(ConfigPolicy.Info).check(
|
||||
Schema.makeFilter((statements) =>
|
||||
statements.every(
|
||||
(statement) =>
|
||||
statement.resource.length > 0 &&
|
||||
statement.resource.length <= 256 &&
|
||||
statement.resource.trim() === statement.resource,
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
managedPolicy: ProviderPolicy.Descriptor,
|
||||
})
|
||||
class RemoteFailure extends Schema.TaggedError<RemoteFailure>()("OpenCode.RemoteConfigFailure", {
|
||||
transient: Schema.Boolean,
|
||||
}) {}
|
||||
const Device = Schema.Struct({
|
||||
device_code: Schema.String,
|
||||
user_code: Schema.String,
|
||||
@@ -102,31 +124,41 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
} satisfies IntegrationOAuthMethodRegistration
|
||||
}
|
||||
|
||||
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope.Scope>({
|
||||
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | ProviderPolicy.Service | Scope.Scope>({
|
||||
id: "opencode.provider.opencode",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const bus = yield* Bus.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const policies = yield* ProviderPolicy.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
|
||||
let snapshot: {
|
||||
config: typeof RemoteResponse.Type | undefined
|
||||
config: Effect.Success<ReturnType<typeof fetchConfig>> | undefined
|
||||
connection: ActiveConnection
|
||||
} = { config: undefined, connection: undefined }
|
||||
identity?: string
|
||||
stale: boolean
|
||||
} = { config: undefined, connection: undefined, stale: false }
|
||||
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
const identity = connection && credential ? ProviderPolicy.identity(connection, credential) : undefined
|
||||
const config = credential
|
||||
? yield* fetchConfig(http, credential).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
||||
Effect.logWarning("Failed to load workspace provider policy", { transient: cause.transient }).pipe(
|
||||
Effect.as(
|
||||
cause.transient && identity !== undefined && snapshot.identity === identity && snapshot.config
|
||||
? { ...snapshot.config, stale: true }
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
return { config, connection }
|
||||
return { config, connection, identity, stale: config?.stale ?? false }
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
@@ -138,6 +170,16 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
})
|
||||
|
||||
snapshot = yield* load()
|
||||
yield* policies.set(
|
||||
snapshot.config && snapshot.identity
|
||||
? {
|
||||
identity: snapshot.identity,
|
||||
descriptor: snapshot.config.managedPolicy,
|
||||
statements: snapshot.config.experimental.policies,
|
||||
stale: snapshot.stale,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
|
||||
const source = catalog.provider.get(item.canonical ?? providerID)
|
||||
@@ -284,6 +326,16 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
|
||||
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
|
||||
snapshot = next
|
||||
yield* policies.set(
|
||||
next.config && next.identity
|
||||
? {
|
||||
identity: next.identity,
|
||||
descriptor: next.config.managedPolicy,
|
||||
statements: next.config.experimental.policies,
|
||||
stale: next.stale,
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
yield* Effect.all([ctx.catalog.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
|
||||
@@ -307,27 +359,49 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
}),
|
||||
})
|
||||
|
||||
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
const fetchConfig = Effect.fn("OpenCode.fetchConfig")(function* (http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
const metadata = value.metadata
|
||||
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
|
||||
const token = value.type === "oauth" ? value.access : value.key
|
||||
return http
|
||||
const server = yield* normalizeServer(serverUrl(value)).pipe(
|
||||
Effect.mapError(() => new RemoteFailure({ transient: false })),
|
||||
)
|
||||
return yield* http
|
||||
.execute(
|
||||
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
|
||||
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(token),
|
||||
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
|
||||
Effect.mapError(() => new RemoteFailure({ transient: true })),
|
||||
Effect.flatMap((response) => {
|
||||
if (response.status === 404) return Effect.undefined
|
||||
return HttpClientResponse.filterStatusOk(response).pipe(
|
||||
if (response.status < 200 || response.status >= 300)
|
||||
return Effect.fail(new RemoteFailure({ transient: response.status >= 500 || response.status === 429 }))
|
||||
return Effect.succeed(response).pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
|
||||
Effect.flatMap((config) =>
|
||||
Schema.decodeUnknownEffect(RemotePolicy, { onExcessProperty: "error" })({
|
||||
experimental: config.experimental,
|
||||
managedPolicy: config.managedPolicy,
|
||||
}).pipe(Effect.map((policy) => ({ ...config, ...policy, stale: false }))),
|
||||
),
|
||||
Effect.flatMap((config) =>
|
||||
orgID !== undefined && config.managedPolicy.workspaceID !== orgID
|
||||
? Effect.fail(new RemoteFailure({ transient: false }))
|
||||
: Effect.succeed(config),
|
||||
),
|
||||
Effect.mapError(() => new RemoteFailure({ transient: false })),
|
||||
)
|
||||
}),
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(20),
|
||||
orElse: () => Effect.fail(new RemoteFailure({ transient: true })),
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function serverUrl(value: Credential.Value) {
|
||||
return typeof value.metadata?.server === "string" ? value.metadata.server : defaultServer
|
||||
|
||||
@@ -88,6 +88,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
enabled.add(plugin.id)
|
||||
}
|
||||
|
||||
// Console's connected-workspace policy source cannot be removed by authored
|
||||
// plugin directives. Its final enforcement lives outside the plugin registry.
|
||||
if (pre.some((plugin) => plugin.id === "opencode.provider.opencode")) enabled.add("opencode.provider.opencode")
|
||||
const ordered = [
|
||||
...pre.filter((plugin) => enabled.has(plugin.id)),
|
||||
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
export * as ProviderPolicy from "./provider-policy.js"
|
||||
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
import { Event } from "@opencode/schema/config"
|
||||
import { Catalog } from "@opencode/schema/catalog"
|
||||
import { ProviderPolicyMatcher } from "@opencode/util/opencode-policy-matcher"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Config } from "./config.js"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { IntegrationConnection } from "./integration/connection.js"
|
||||
|
||||
export class Unavailable extends Schema.TaggedError<Unavailable>()("ProviderPolicy.Unavailable", {}) {
|
||||
override get message() {
|
||||
return "Workspace policy unavailable. Reconnect to OpenCode Console and try again."
|
||||
}
|
||||
}
|
||||
export class Denied extends Schema.TaggedError<Denied>()("ProviderPolicy.Denied", { providerID: Schema.String }) {
|
||||
override get message() {
|
||||
return `Provider ${this.providerID} is denied by your OpenCode provider policy.`
|
||||
}
|
||||
}
|
||||
|
||||
export const Descriptor = Schema.Struct({
|
||||
schemaVersion: Schema.Literal(1),
|
||||
workspaceID: Schema.String.check(Schema.isNonEmpty()),
|
||||
revision: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
})
|
||||
|
||||
export type Managed = {
|
||||
readonly identity: string
|
||||
readonly descriptor: typeof Descriptor.Type
|
||||
readonly statements: readonly ConfigPolicy.Info[]
|
||||
readonly stale: boolean
|
||||
}
|
||||
export type Snapshot = {
|
||||
readonly status: "disconnected" | "ready" | "stale" | "unavailable"
|
||||
readonly statements: readonly ConfigPolicy.Info[]
|
||||
readonly workspaceID?: string
|
||||
readonly revision?: number
|
||||
}
|
||||
export interface Interface {
|
||||
readonly read: () => Effect.Effect<Snapshot>
|
||||
readonly set: (value: Managed | undefined) => Effect.Effect<void>
|
||||
readonly assert: (providerID?: string) => Effect.Effect<void, Unavailable | Denied>
|
||||
}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProviderPolicy") {}
|
||||
|
||||
// Internal connection binding; this value can contain a service key and must never be logged or exposed.
|
||||
export function identity(
|
||||
connection: Pick<IntegrationConnection.Info, "type"> & { readonly id?: string; readonly name?: string },
|
||||
credential: Credential.Value,
|
||||
) {
|
||||
return JSON.stringify([
|
||||
connection.type,
|
||||
connection.id ?? connection.name,
|
||||
credential.metadata?.server ?? "https://opencode.ai/console",
|
||||
credential.metadata?.orgID,
|
||||
credential.type === "key" ? credential.key : undefined,
|
||||
])
|
||||
}
|
||||
|
||||
export function allows(snapshot: Snapshot, providerID: string) {
|
||||
return (
|
||||
snapshot.status !== "unavailable" &&
|
||||
ProviderPolicyMatcher.evaluate(snapshot.statements, providerID, process.platform === "win32").effect !== "deny"
|
||||
)
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const bus = yield* Bus.Service
|
||||
let managed: Managed | undefined
|
||||
const read = Effect.fn("ProviderPolicy.read")(function* () {
|
||||
const statements = (yield* config.entries())
|
||||
.filter((entry) => entry.type === "document")
|
||||
.toReversed()
|
||||
.flatMap((entry) => entry.info.experimental?.policies ?? [])
|
||||
const connection = yield* integrations.connection.active(Integration.ID.make("opencode"))
|
||||
if (!connection) return { status: "disconnected", statements } as const
|
||||
const credential =
|
||||
connection.type === "credential"
|
||||
? (yield* credentials.get(connection.id))?.value
|
||||
: process.env[connection.name]
|
||||
? Credential.Key.make({ type: "key", key: process.env[connection.name]! })
|
||||
: undefined
|
||||
if (!credential || !managed || managed.identity !== identity(connection, credential))
|
||||
return { status: "unavailable", statements } as const
|
||||
return {
|
||||
status: managed.stale ? "stale" : "ready",
|
||||
statements: [...statements, ...managed.statements],
|
||||
workspaceID: managed.descriptor.workspaceID,
|
||||
revision: managed.descriptor.revision,
|
||||
} as const
|
||||
})
|
||||
yield* bus.subscribe(Event.Updated).pipe(
|
||||
Stream.runForEach(() => bus.publish(Catalog.Event.Updated, {})),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({
|
||||
read,
|
||||
set: (value) =>
|
||||
Effect.sync(() => {
|
||||
managed = value
|
||||
}),
|
||||
assert: Effect.fn("ProviderPolicy.assert")(function* (providerID) {
|
||||
const snapshot = yield* read()
|
||||
if (snapshot.status === "unavailable") return yield* new Unavailable()
|
||||
if (providerID !== undefined && !allows(snapshot, providerID)) return yield* new Denied({ providerID })
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, Integration.node, Credential.node, Bus.node],
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { Model } from "@opencode/schema/model"
|
||||
import { Provider } from "@opencode/schema/provider"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ModelResolver } from "../../model-resolver.js"
|
||||
import { ProviderPolicy } from "../../provider-policy.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
|
||||
export class ModelNotSelectedError extends Schema.TaggedError<ModelNotSelectedError>()(
|
||||
@@ -80,8 +81,10 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const resolver = yield* ModelResolver.Service
|
||||
const policies = yield* ProviderPolicy.Service
|
||||
return Service.of({
|
||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session, available) {
|
||||
yield* policies.assert(session.model?.providerID)
|
||||
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
||||
if (!session.model) {
|
||||
const resolved = yield* resolver.resolve()
|
||||
@@ -102,4 +105,4 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [ModelResolver.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [ModelResolver.node, ProviderPolicy.node] })
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Tool } from "@opencode/schema/tool"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Integration } from "../integration.js"
|
||||
import { ProviderPolicy } from "../provider-policy.js"
|
||||
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
|
||||
@@ -51,6 +52,8 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
|
||||
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
|
||||
if (
|
||||
cause instanceof ProviderPolicy.Unavailable ||
|
||||
cause instanceof ProviderPolicy.Denied ||
|
||||
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
|
||||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
|
||||
|
||||
@@ -2,10 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Document, Event, Info, type Entry } from "@opencode/schema/config"
|
||||
import { Catalog } from "@opencode/core/catalog"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -24,13 +21,13 @@ const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
|
||||
}),
|
||||
})
|
||||
|
||||
const addPlugin = Effect.fn(function* (entries: Entry[]) {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
|
||||
const setEntries = Effect.fn(function* (entries: Entry[]) {
|
||||
const config = yield* Config.Service
|
||||
const current = yield* config.entries()
|
||||
current.splice(0, current.length, ...entries)
|
||||
})
|
||||
|
||||
describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
describe("Provider policy catalog boundary", () => {
|
||||
it.effect("filters plugin-provided providers with ordered wildcard policies", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -39,7 +36,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
catalog.provider.update(Provider.ID.anthropic, () => {})
|
||||
catalog.provider.update(Provider.ID.make("company-internal"), () => {})
|
||||
})
|
||||
yield* addPlugin([
|
||||
yield* setEntries([
|
||||
policies(
|
||||
{ effect: "deny", resource: "*" },
|
||||
{ effect: "allow", resource: "anthropic" },
|
||||
@@ -57,7 +54,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.openai, () => {}))
|
||||
yield* addPlugin([
|
||||
yield* setEntries([
|
||||
policies({ effect: "deny", resource: "openai" }),
|
||||
policies({ effect: "allow", resource: "openai" }),
|
||||
])
|
||||
@@ -70,17 +67,14 @@ describe("ConfigPolicyPlugin.Plugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const bus = yield* Bus.Service
|
||||
const test = yield* Config.Test
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.openai, () => {}))
|
||||
yield* ConfigPolicyPlugin.Plugin.effect(host)
|
||||
yield* setEntries([policies({ effect: "deny", resource: "openai" })])
|
||||
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
|
||||
|
||||
yield* test.setEntries([policies({ effect: "allow", resource: "openai" })])
|
||||
yield* setEntries([policies({ effect: "allow", resource: "openai" })])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* waitUntil(catalog.provider.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined)))
|
||||
}).pipe(Effect.provide(Config.testLayer([policies({ effect: "deny", resource: "openai" })]))),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProviderPolicy } from "@opencode/core/provider-policy"
|
||||
import { expect } from "bun:test"
|
||||
import { LanguageModel } from "@opencode/ai"
|
||||
import { OpenAIChat } from "@opencode/ai/protocols"
|
||||
@@ -67,7 +68,10 @@ const aisdk = Layer.mock(AISDK.Service, {
|
||||
})
|
||||
const client = TestLLM.testLayer({ fallback: TestLLM.text("OK", "generate") })
|
||||
|
||||
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
||||
const resolver = ModelResolver.layer.pipe(
|
||||
Layer.provide(Layer.mock(ProviderPolicy.Service, { assert: () => Effect.void })),
|
||||
Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)),
|
||||
)
|
||||
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
|
||||
const resolverIt = testEffect(resolver)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProviderPolicy } from "@opencode/core/provider-policy"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LLM, LanguageModel, Message } from "@opencode/ai"
|
||||
import { OpenAIChat } from "@opencode/ai/protocols"
|
||||
@@ -384,7 +385,10 @@ describe("ModelResolver", () => {
|
||||
},
|
||||
model: () => Effect.die("unused"),
|
||||
})
|
||||
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
||||
const layer = ModelResolver.layer.pipe(
|
||||
Layer.provide(Layer.mock(ProviderPolicy.Service, { assert: () => Effect.void })),
|
||||
Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)),
|
||||
)
|
||||
|
||||
return withConfigEnv({}, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { AISDK } from "@opencode/core/aisdk"
|
||||
import { Catalog } from "@opencode/core/catalog"
|
||||
import { ProviderPolicy } from "@opencode/core/provider-policy"
|
||||
import { Command } from "@opencode/core/command"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
@@ -78,6 +79,8 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
Agent.node,
|
||||
AISDK.node,
|
||||
Catalog.node,
|
||||
ProviderPolicy.node,
|
||||
Config.node,
|
||||
Command.node,
|
||||
Integration.node,
|
||||
KV.node,
|
||||
|
||||
@@ -89,6 +89,10 @@ function eventually<A>(
|
||||
})
|
||||
}
|
||||
|
||||
function managedPolicy(workspaceID = "org_test") {
|
||||
return { experimental: { policies: [] }, managedPolicy: { schemaVersion: 1, workspaceID, revision: 0 } }
|
||||
}
|
||||
|
||||
const cost = (input: number, output = 0) => [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(input),
|
||||
@@ -366,6 +370,7 @@ describe("OpencodePlugin", () => {
|
||||
requests.push(`${request.method} ${new URL(request.url).pathname}`)
|
||||
const origin = new URL(request.url).origin
|
||||
return Response.json({
|
||||
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
|
||||
providers: {
|
||||
remote: {
|
||||
canonical: "openai",
|
||||
@@ -544,9 +549,10 @@ describe("OpencodePlugin", () => {
|
||||
const state = { advertised: false, requests: 0 }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => {
|
||||
fetch: (request) => {
|
||||
state.requests++
|
||||
return Response.json({
|
||||
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
|
||||
providers: {},
|
||||
...(state.advertised ? { websearch: { providerID: "opencode" } } : {}),
|
||||
})
|
||||
@@ -634,6 +640,7 @@ describe("OpencodePlugin", () => {
|
||||
if (path === "/api/v2/config") {
|
||||
if (state.waitForConfig) await gate.promise
|
||||
return Response.json({
|
||||
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
|
||||
providers: {},
|
||||
...(state.advertised
|
||||
? {
|
||||
@@ -781,6 +788,7 @@ describe("OpencodePlugin", () => {
|
||||
fetch: (request) => {
|
||||
if (new URL(request.url).pathname === "/console/api/v2/config") {
|
||||
return Response.json({
|
||||
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
|
||||
providers: {},
|
||||
websearch: {
|
||||
providerID: "managed-search",
|
||||
@@ -843,6 +851,7 @@ describe("OpencodePlugin", () => {
|
||||
requests.push(url.pathname)
|
||||
if (url.pathname === "/console/api/v2/config") {
|
||||
return Response.json({
|
||||
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
|
||||
providers: {},
|
||||
websearch: {
|
||||
providerID: "opencode",
|
||||
@@ -893,6 +902,7 @@ describe("OpencodePlugin", () => {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/v2/config") {
|
||||
return Response.json({
|
||||
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
|
||||
providers: {},
|
||||
websearch: {
|
||||
providerID: "opencode",
|
||||
@@ -987,6 +997,7 @@ describe("OpencodePlugin", () => {
|
||||
})
|
||||
}
|
||||
return Response.json({
|
||||
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
|
||||
providers: {
|
||||
remote: {
|
||||
canonical: "openai",
|
||||
@@ -1152,13 +1163,12 @@ describe("OpencodePlugin", () => {
|
||||
draft.cost = cost(1)
|
||||
})
|
||||
})
|
||||
// An env credential has no server metadata, so the plugin would ask the
|
||||
// default Console for remote config; answer 404 (no remote config) locally.
|
||||
// Environment credentials also require a valid managed-policy response.
|
||||
yield* addPlugin().pipe(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))),
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ providers: {}, ...managedPolicy() }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Catalog } from "@opencode/core/catalog"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { ProviderPolicy } from "@opencode/core/provider-policy"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
// Run from Console's policy E2E with a freshly issued local service-account key.
|
||||
describe.skipIf(!process.env.OPENCODE_CONSOLE_POLICY_URL)("Published Console policy", () => {
|
||||
it.live("loads the real managed config and enforces the policy published in the browser", () =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const policies = yield* ProviderPolicy.Service
|
||||
yield* catalog.transform((editor) => {
|
||||
editor.provider.update(Provider.ID.openai, () => {})
|
||||
editor.provider.update(Provider.ID.make("company-prod"), () => {})
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: process.env.OPENCODE_CONSOLE_POLICY_KEY!,
|
||||
metadata: {
|
||||
server: process.env.OPENCODE_CONSOLE_POLICY_URL!,
|
||||
orgID: process.env.OPENCODE_CONSOLE_POLICY_ORG!,
|
||||
},
|
||||
}),
|
||||
})
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* OpencodePlugin.effect(host)
|
||||
expect(yield* policies.read()).toMatchObject({
|
||||
status: "ready",
|
||||
workspaceID: process.env.OPENCODE_CONSOLE_POLICY_ORG,
|
||||
revision: 1,
|
||||
})
|
||||
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
|
||||
expect(yield* catalog.provider.get(Provider.ID.make("company-prod"))).toBeDefined()
|
||||
expect(yield* policies.assert("openai").pipe(Effect.flip)).toBeInstanceOf(ProviderPolicy.Denied)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Catalog } from "@opencode/core/catalog"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { ModelResolver } from "@opencode/core/model-resolver"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { ProviderPolicy } from "@opencode/core/provider-policy"
|
||||
import { Document, Info } from "@opencode/schema/config"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { drain } from "../lib/clock"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const rule = (effect: "allow" | "deny", resource: string) => ({ action: "provider.use" as const, effect, resource })
|
||||
const document = (policies: ReturnType<typeof rule>[], workspaceID = "org_policy", revision = 1) => ({
|
||||
providers: {},
|
||||
experimental: { policies },
|
||||
managedPolicy: { schemaVersion: 1, workspaceID, revision },
|
||||
})
|
||||
const activate = Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* OpencodePlugin.effect(host)
|
||||
})
|
||||
const refresh = TestClock.adjust("10 minutes").pipe(Effect.andThen(drain))
|
||||
|
||||
describe("Managed provider policy", () => {
|
||||
it.effect("accepts revision-zero defaults without requiring policy publication", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => Bun.serve({ port: 0, fetch: () => Response.json(document([], "org_policy", 0)) })),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const policies = yield* ProviderPolicy.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(Provider.ID.openai, () => {}))
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "test-key",
|
||||
metadata: { server: server.url.origin, orgID: "org_policy" },
|
||||
}),
|
||||
})
|
||||
yield* activate
|
||||
expect(yield* policies.read()).toMatchObject({ status: "ready", revision: 0, workspaceID: "org_policy" })
|
||||
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeDefined()
|
||||
yield* policies.assert("openai")
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"composes organization rules last, filters every catalog view, retains only valid same-identity snapshots, and clears explicitly",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { status: 200, body: document([rule("deny", "*"), rule("allow", "remote")]) as unknown }
|
||||
const server = Bun.serve({ port: 0, fetch: () => Response.json(state.body, { status: state.status }) })
|
||||
return { state, server }
|
||||
}),
|
||||
({ state, server }) =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const policies = yield* ProviderPolicy.Service
|
||||
const config = yield* Config.Service
|
||||
const entries = yield* config.entries()
|
||||
entries.push(
|
||||
new Document({
|
||||
type: "document",
|
||||
info: Schema.decodeUnknownSync(Info)({
|
||||
experimental: { policies: [rule("deny", "remote"), rule("allow", "openai")] },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
yield* catalog.transform((editor) => {
|
||||
for (const id of ["openai", "remote"]) {
|
||||
editor.provider.update(Provider.ID.make(id), (provider) => {
|
||||
provider.activation = "enabled"
|
||||
})
|
||||
editor.model.update(Provider.ID.make(id), Model.ID.make("model"), () => {})
|
||||
}
|
||||
})
|
||||
const previouslySelected = yield* catalog.model.get(Provider.ID.openai, Model.ID.make("model"))
|
||||
if (!previouslySelected) return yield* Effect.die("Expected local model")
|
||||
const credential = yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "test-key",
|
||||
metadata: { server: server.url.origin, orgID: "org_policy" },
|
||||
}),
|
||||
})
|
||||
expect((yield* policies.read()).status).toBe("unavailable")
|
||||
expect(yield* catalog.provider.all()).toEqual([])
|
||||
yield* activate
|
||||
expect((yield* policies.read()).status).toBe("ready")
|
||||
expect(yield* catalog.provider.get(Provider.ID.make("remote"))).toBeDefined()
|
||||
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
|
||||
expect((yield* catalog.model.all()).map((model) => model.providerID)).toEqual([Provider.ID.make("remote")])
|
||||
expect(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("model"))).toBeUndefined()
|
||||
expect(yield* catalog.model.small(Provider.ID.openai)).toBeUndefined()
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.provider.update(Provider.ID.openai, (provider) => {
|
||||
provider.activation = "enabled"
|
||||
}),
|
||||
)
|
||||
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
|
||||
expect(yield* policies.assert("openai").pipe(Effect.flip)).toBeInstanceOf(ProviderPolicy.Denied)
|
||||
const loadError = yield* Effect.gen(function* () {
|
||||
const resolver = yield* ModelResolver.Service
|
||||
return yield* resolver.resolveModel(previouslySelected).pipe(Effect.flip)
|
||||
}).pipe(Effect.provide(ModelResolver.layer))
|
||||
expect(loadError).toBeInstanceOf(ProviderPolicy.Denied)
|
||||
state.status = 503
|
||||
yield* refresh
|
||||
expect((yield* policies.read()).status).toBe("stale")
|
||||
expect(yield* catalog.provider.get(Provider.ID.make("remote"))).toBeDefined()
|
||||
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
|
||||
state.status = 200
|
||||
state.body = { providers: {} }
|
||||
yield* refresh
|
||||
expect((yield* policies.read()).status).toBe("unavailable")
|
||||
expect(yield* catalog.model.available()).toEqual([])
|
||||
expect(yield* policies.assert("remote").pipe(Effect.flip)).toBeInstanceOf(ProviderPolicy.Unavailable)
|
||||
state.body = document([], "org_policy", 2)
|
||||
yield* refresh
|
||||
expect((yield* policies.read()).revision).toBe(2)
|
||||
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeDefined()
|
||||
expect(yield* catalog.provider.get(Provider.ID.make("remote"))).toBeUndefined()
|
||||
state.status = 503
|
||||
yield* credentials.update(credential.id, {
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "other-key",
|
||||
metadata: { server: server.url.origin, orgID: "org_other" },
|
||||
}),
|
||||
})
|
||||
yield* drain
|
||||
expect((yield* policies.read()).status).toBe("unavailable")
|
||||
state.status = 200
|
||||
yield* refresh
|
||||
expect((yield* policies.read()).status).toBe("unavailable")
|
||||
state.body = document([rule("deny", "*")], "org_other", 0)
|
||||
yield* refresh
|
||||
expect((yield* policies.read()).workspaceID).toBe("org_other")
|
||||
expect(yield* catalog.provider.all()).toEqual([])
|
||||
yield* credentials.remove(credential.id)
|
||||
yield* drain
|
||||
expect((yield* policies.read()).status).toBe("disconnected")
|
||||
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeDefined()
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
for (const scenario of [
|
||||
{ name: "missing descriptor", body: { providers: {}, experimental: { policies: [] } }, status: 200 },
|
||||
{
|
||||
name: "unsupported version",
|
||||
body: { ...document([]), managedPolicy: { schemaVersion: 2, workspaceID: "org_policy", revision: 1 } },
|
||||
status: 200,
|
||||
},
|
||||
{ name: "unknown conditions", body: document([{ ...rule("allow", "*"), ...{ condition: {} } }]), status: 200 },
|
||||
{ name: "empty resource", body: document([rule("allow", "")]), status: 200 },
|
||||
{ name: "absent policies", body: { ...document([]), experimental: {} }, status: 200 },
|
||||
{ name: "revoked authorization", body: document([]), status: 403 },
|
||||
{ name: "old Console", body: {}, status: 404 },
|
||||
{ name: "cold outage", body: {}, status: 503 },
|
||||
]) {
|
||||
it.effect(`fails closed on ${scenario.name}`, () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({ port: 0, fetch: () => Response.json(scenario.body, { status: scenario.status }) }),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const policies = yield* ProviderPolicy.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(Provider.ID.openai, () => {}))
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "test-key",
|
||||
metadata: { server: server.url.origin, orgID: "org_policy" },
|
||||
}),
|
||||
})
|
||||
yield* activate
|
||||
expect((yield* policies.read()).status).toBe("unavailable")
|
||||
expect(yield* catalog.provider.all()).toEqual([])
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { Command } from "@opencode/core/command"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
@@ -111,6 +112,36 @@ const failed = (plugins: Plugin.Interface) =>
|
||||
)
|
||||
|
||||
describe("PluginSupervisor reload", () => {
|
||||
for (const selector of ["-*", "-opencode.*", "-opencode.provider.opencode"]) {
|
||||
it.live(`keeps the managed policy source active despite ${selector}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(directory.path, ".opencode/opencode.json"), JSON.stringify({ plugins: [selector] })),
|
||||
)
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const config = yield* Config.Service
|
||||
expect(
|
||||
(yield* config.entries()).some(
|
||||
(entry) => entry.type === "document" && entry.info.plugins?.includes(selector),
|
||||
),
|
||||
).toBe(true)
|
||||
const inventory = yield* plugins.list()
|
||||
expect(inventory.find((plugin) => plugin.id === "opencode.provider.opencode")?.state.status).toBe("active")
|
||||
if (selector !== "-opencode.provider.opencode")
|
||||
expect(
|
||||
inventory.some((plugin) => plugin.id === "opencode.provider.openai" && plugin.state.status === "active"),
|
||||
).toBe(false)
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
;(
|
||||
[
|
||||
{ name: "on a helper-only save", helper: "nested/helper.ts", touchEntry: false },
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export * as ProviderPolicyMatcher from "./opencode-policy-matcher.js"
|
||||
|
||||
// Shared verbatim with OpenCode packages/util/src/opencode-policy-matcher.ts.
|
||||
// Keep the paired conformance check passing when changing these semantics.
|
||||
export type Statement = {
|
||||
readonly action: "provider.use"
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny"
|
||||
}
|
||||
|
||||
export function match(input: string, pattern: string, windows: boolean) {
|
||||
const normalized = input.replaceAll("\\", "/")
|
||||
const escaped = pattern
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".")
|
||||
const expression = escaped.endsWith(" .*") ? escaped.slice(0, -3) + "( .*)?" : escaped
|
||||
return new RegExp("^" + expression + "$", windows ? "si" : "s").test(normalized)
|
||||
}
|
||||
|
||||
export function evaluate(statements: readonly Statement[], resource: string, windows: boolean) {
|
||||
const index = statements.findLastIndex((statement) => match(resource, statement.resource, windows))
|
||||
return { effect: index === -1 ? ("inherit" as const) : statements[index]!.effect, index: index === -1 ? null : index }
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
[
|
||||
{
|
||||
"name": "no organization override",
|
||||
"rules": [],
|
||||
"resource": "openai",
|
||||
"unix": "inherit",
|
||||
"windows": "inherit",
|
||||
"index": null
|
||||
},
|
||||
{
|
||||
"name": "last match wins",
|
||||
"rules": [
|
||||
{
|
||||
"action": "provider.use",
|
||||
"effect": "deny",
|
||||
"resource": "*"
|
||||
},
|
||||
{
|
||||
"action": "provider.use",
|
||||
"effect": "allow",
|
||||
"resource": "openai"
|
||||
}
|
||||
],
|
||||
"resource": "openai",
|
||||
"unix": "allow",
|
||||
"windows": "allow",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"name": "wildcard after exact wins",
|
||||
"rules": [
|
||||
{
|
||||
"action": "provider.use",
|
||||
"effect": "allow",
|
||||
"resource": "openai"
|
||||
},
|
||||
{
|
||||
"action": "provider.use",
|
||||
"effect": "deny",
|
||||
"resource": "*"
|
||||
}
|
||||
],
|
||||
"resource": "openai",
|
||||
"unix": "deny",
|
||||
"windows": "deny",
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"name": "anchored wildcard",
|
||||
"rules": [
|
||||
{
|
||||
"action": "provider.use",
|
||||
"effect": "deny",
|
||||
"resource": "company-*"
|
||||
}
|
||||
],
|
||||
"resource": "not-company-a",
|
||||
"unix": "inherit",
|
||||
"windows": "inherit",
|
||||
"index": null
|
||||
},
|
||||
{
|
||||
"name": "question matches one",
|
||||
"rules": [
|
||||
{
|
||||
"action": "provider.use",
|
||||
"effect": "deny",
|
||||
"resource": "company-?"
|
||||
}
|
||||
],
|
||||
"resource": "company-ab",
|
||||
"unix": "inherit",
|
||||
"windows": "inherit",
|
||||
"index": null
|
||||
},
|
||||
{
|
||||
"name": "slash normalization",
|
||||
"rules": [
|
||||
{
|
||||
"action": "provider.use",
|
||||
"effect": "allow",
|
||||
"resource": "company/*"
|
||||
}
|
||||
],
|
||||
"resource": "company\\model",
|
||||
"unix": "allow",
|
||||
"windows": "allow",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"name": "case differs by platform",
|
||||
"rules": [
|
||||
{
|
||||
"action": "provider.use",
|
||||
"effect": "deny",
|
||||
"resource": "OpenAI"
|
||||
}
|
||||
],
|
||||
"resource": "openai",
|
||||
"unix": "inherit",
|
||||
"windows": "deny",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"name": "regex characters are literal",
|
||||
"rules": [
|
||||
{
|
||||
"action": "provider.use",
|
||||
"effect": "deny",
|
||||
"resource": "company.[ab]"
|
||||
}
|
||||
],
|
||||
"resource": "company.a",
|
||||
"unix": "inherit",
|
||||
"windows": "inherit",
|
||||
"index": null
|
||||
},
|
||||
{
|
||||
"name": "trailing wildcard space",
|
||||
"rules": [
|
||||
{
|
||||
"action": "provider.use",
|
||||
"effect": "allow",
|
||||
"resource": "company *"
|
||||
}
|
||||
],
|
||||
"resource": "company",
|
||||
"unix": "allow",
|
||||
"windows": "allow",
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"name": "question matches exactly one",
|
||||
"rules": [
|
||||
{
|
||||
"action": "provider.use",
|
||||
"effect": "deny",
|
||||
"resource": "comp?ny"
|
||||
}
|
||||
],
|
||||
"resource": "company",
|
||||
"unix": "deny",
|
||||
"windows": "deny",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, it } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { ProviderPolicyMatcher } from "../src/opencode-policy-matcher.js"
|
||||
|
||||
const cases = Schema.decodeUnknownSync(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
name: Schema.String,
|
||||
rules: Schema.Array(
|
||||
Schema.Struct({
|
||||
action: Schema.Literal("provider.use"),
|
||||
effect: Schema.Literals(["allow", "deny"]),
|
||||
resource: Schema.String,
|
||||
}),
|
||||
),
|
||||
resource: Schema.String,
|
||||
unix: Schema.Literals(["allow", "deny", "inherit"]),
|
||||
windows: Schema.Literals(["allow", "deny", "inherit"]),
|
||||
index: Schema.NullOr(Schema.Int),
|
||||
}),
|
||||
),
|
||||
)(await Bun.file(new URL("./opencode-policy-cases.json", import.meta.url)).json())
|
||||
|
||||
for (const scenario of cases) {
|
||||
it(scenario.name, () => {
|
||||
for (const windows of [false, true]) {
|
||||
const effect = windows ? scenario.windows : scenario.unix
|
||||
expect(ProviderPolicyMatcher.evaluate(scenario.rules, scenario.resource, windows)).toEqual({
|
||||
effect,
|
||||
index: effect === "inherit" ? null : scenario.index,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
+69
-33
@@ -1,10 +1,10 @@
|
||||
# Provider Policy
|
||||
|
||||
Status: **Implemented.**
|
||||
Status: **Implemented, including paired Console managed-policy support in the working tree.** Release and deployment are separate; this spec does not name a minimum released client version.
|
||||
|
||||
## Purpose
|
||||
|
||||
Policies control whether an operation on a named resource is allowed. Statements are authored in configuration files and applied by a terminal catalog plugin.
|
||||
Policies control whether an operation on a named resource is allowed. Statements come from authored configuration and, while connected to Console, an authenticated managed response. A core policy service enforces the combined result at catalog reads and model resolution.
|
||||
|
||||
The first policy consumer is provider availability:
|
||||
|
||||
@@ -24,9 +24,9 @@ A provider can be correctly configured and have valid credentials while policy s
|
||||
|
||||
- Replace legacy `enabled_providers` and `disabled_providers`.
|
||||
- Keep the default experience unchanged when users specify no policy.
|
||||
- Support wildcard matching for actions and resources.
|
||||
- Provide one small policy vocabulary that can later cover operations such as `plugin.load` or `mcp.connect`.
|
||||
- Let user policy override repository policy, and later allow organization-managed policy to override both.
|
||||
- Support wildcard matching for provider-ID resources.
|
||||
- Support the literal `provider.use` action; other actions require separate implementation.
|
||||
- Let user policy override repository policy, and organization-managed policy override both.
|
||||
- Keep evaluation simple: matching statements are applied in order and the last match wins.
|
||||
|
||||
## Non-Goals
|
||||
@@ -34,7 +34,7 @@ A provider can be correctly configured and have valid credentials while policy s
|
||||
- Policies do not configure endpoints, credentials, models, or provider options.
|
||||
- Policies do not make unusable resources usable.
|
||||
- Policies do not currently provide conditions, principals, approval prompts, or enforced configuration values.
|
||||
- This spec does not define how organization-managed policies are delivered.
|
||||
- Policies do not pin a provider's endpoint, require gateway routing, enroll devices, sandbox arbitrary executable plugins, or cancel in-flight requests.
|
||||
|
||||
## Statement Shape
|
||||
|
||||
@@ -55,24 +55,24 @@ A provider can be correctly configured and have valid credentials while policy s
|
||||
```ts
|
||||
interface PolicyInfo {
|
||||
effect: "allow" | "deny"
|
||||
action: string
|
||||
action: "provider.use"
|
||||
resource: string
|
||||
}
|
||||
```
|
||||
|
||||
`ConfigPolicy` owns the statement schema. The policy plugin interprets the supported `provider.use` action after all other catalog transforms have run.
|
||||
`ConfigPolicy` owns the statement schema. `ProviderPolicy` interprets it outside the removable plugin registry. Managed responses validate the complete policy strictly: unknown fields/actions/effects, empty or untrimmed resources, and resources over 256 characters are invalid. Console allows up to 100 custom statements; generated presets may have more.
|
||||
|
||||
## Matching
|
||||
|
||||
Both `action` and `resource` use opencode's existing wildcard matching behavior.
|
||||
The action must be exactly `provider.use`; `provider.*` is invalid. Resources use anchored `*` (zero or more characters) and `?` (one character) wildcards. Backslashes normalize to slashes. Windows matches case-insensitively; macOS and Linux match case-sensitively. Matching retains the existing optional trailing ` *` behavior. Patterns are not regular expressions or IAM resource names.
|
||||
|
||||
Examples:
|
||||
|
||||
| Action | Resource | Matches |
|
||||
| -------------- | ----------- | ---------------------------------------------------------------------------- |
|
||||
| `provider.use` | `openai` | Only use of provider ID `openai` |
|
||||
| `provider.use` | `company-*` | Use of provider IDs such as `company-us` and `company-eu` |
|
||||
| `provider.*` | `*` | Any provider operation on any provider, if more actions are introduced later |
|
||||
| Action | Resource | Matches |
|
||||
| -------------- | ----------- | --------------------------------------------------------- |
|
||||
| `provider.use` | `openai` | Only use of provider ID `openai` |
|
||||
| `provider.use` | `company-*` | Use of provider IDs such as `company-us` and `company-eu` |
|
||||
| `provider.use` | `*` | Every provider ID |
|
||||
|
||||
No pattern-specific precedence exists. A specific resource does not automatically beat a wildcard resource. Written/evaluation order controls the result.
|
||||
|
||||
@@ -81,23 +81,18 @@ No pattern-specific precedence exists. A specific resource does not automaticall
|
||||
To evaluate an operation and resource:
|
||||
|
||||
1. Start with `allow`.
|
||||
2. Consider every statement whose `action` and `resource` match the requested action and resource.
|
||||
2. Consider every `provider.use` statement whose resource matches the provider ID.
|
||||
3. Each matching statement replaces the current decision with its `effect`.
|
||||
4. The last matching statement determines the result.
|
||||
|
||||
Conceptually:
|
||||
|
||||
```ts
|
||||
function evaluate(action: string, resource: string, fallback: Policy.Effect, statements: Policy.Info[]) {
|
||||
return (
|
||||
statements.findLast(
|
||||
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
|
||||
)?.effect ?? fallback
|
||||
)
|
||||
}
|
||||
const decision = ProviderPolicyMatcher.evaluate(statements, providerID, process.platform === "win32")
|
||||
const allowed = decision.effect !== "deny"
|
||||
```
|
||||
|
||||
Each caller supplies the default effect appropriate for its operation. Catalog provider use supplies `"allow"`, so no provider policy statements means normal behavior continues: otherwise usable providers are allowed.
|
||||
The pure matcher returns `allow`, `deny`, or `inherit`, plus the winning statement index. `inherit` permits otherwise usable providers. An active Console connection without a valid compatible managed snapshot is separately gated as unavailable; it never falls back to unrestricted provider use.
|
||||
|
||||
## Ordering Within One Config Document
|
||||
|
||||
@@ -193,7 +188,7 @@ The relative policy precedence of direct project files and `.opencode` files is
|
||||
|
||||
## Organization-Managed Policy
|
||||
|
||||
Organization-managed policy is not ordinary authored config. When implemented, managed statements must be appended after the reversed authored statements so they have final authority.
|
||||
Organization-managed policy is not ordinary authored config. Managed statements append after the reversed authored statements, before a single final evaluation. This lets an organization allow override a user deny without trying to resurrect a provider already removed by an earlier policy filter.
|
||||
|
||||
```text
|
||||
repository policy -> user-global policy -> organization-managed policy
|
||||
@@ -201,18 +196,56 @@ repository policy -> user-global policy -> organization-managed policy
|
||||
|
||||
Plugins must not be allowed to add, remove, or override policy statements. Plugins can contribute functionality or configured providers; policy determines whether opencode permits an operation through its managed execution paths.
|
||||
|
||||
The built-in `opencode.provider.opencode` loader remains active under ordinary removal selectors such as `-*`, `-opencode.*`, and `-opencode.provider.opencode`. The policy service is not a removable plugin, and policy mutation is not exposed through the plugin context API. Late catalog transforms still pass through policy checks when read.
|
||||
|
||||
Provider policy is not a full sandbox for executable plugins. A denied provider must not be usable through the normal provider/model path, but arbitrary plugin code requires separate governance if that becomes a compliance requirement.
|
||||
|
||||
### Console contract
|
||||
|
||||
The authenticated `GET /api/v2/config` response includes providers and required managed fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {},
|
||||
"experimental": { "policies": [] },
|
||||
"managedPolicy": {
|
||||
"schemaVersion": 1,
|
||||
"workspaceID": "org_example",
|
||||
"revision": 7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The loader validates the policy without silently dropping unsupported fields. Trust comes from the authenticated loader and active connection, not the presence of a descriptor in a local file. Policy and provider data are applied from the same validated response. The descriptor identifies the saved authoring revision; generated preset output can also change when Console connections change. Refresh compares the whole snapshot, not only the revision.
|
||||
|
||||
Console offers default access, only workspace provider IDs, and custom ordered rules. Default access denies `opencode` when Console emits no connection with that ID. Managed-only denies `*` then allows each exact emitted ID. Custom `[]` explicitly removes organization overrides. Custom mode replaces the legacy v1 managed-only setting; it is not converted to v1 rules.
|
||||
|
||||
An org that has never published policies still receives a valid revision-zero descriptor and rules derived from its existing provider settings, even with Console's publication flag disabled. The client accepts defaults without requiring administrator setup. The current client nevertheless requires a valid response for every connected org: a cold-start outage or old server blocks even a policy-free org because its policy state is not yet known. There is no persisted opt-in exemption before that first fetch.
|
||||
|
||||
### Connection and failure behavior
|
||||
|
||||
- Disconnected: apply authored local rules.
|
||||
- Connected before a valid compatible snapshot: block all normal provider/model resolution, including local providers, with an actionable policy-unavailable error.
|
||||
- Valid response: apply managed rules after local rules.
|
||||
- Same-identity transient refresh failure (network, timeout, 429, or 5xx): retain the last validated in-memory snapshot and mark it stale internally.
|
||||
- Missing, malformed, unsupported, wrong-workspace, or authorization-rejected response: mark policy unavailable. Failure never means an empty policy.
|
||||
- Server, workspace, or service-key switch: require a valid snapshot for the new identity. Never carry the previous identity's rules forward.
|
||||
- Valid explicit `[]`: clear organization overrides; local rules remain.
|
||||
|
||||
Binding includes connection identity, server, selected organization metadata, and the service key where applicable. The response workspace must match organization metadata when present. OAuth access-token refresh does not change this identity. The raw binding stays internal and must never be logged or exposed.
|
||||
|
||||
The existing loader polls every ten minutes and refreshes on connection changes. There is no durable offline cache, fleet acknowledgment, or in-flight revocation. Disconnecting Console leaves the scope of this feature.
|
||||
|
||||
Deploy Console's required response contract first, release the compatible client next, then enable Console's `opencode-policies` publication flag. A new client connected to an old server intentionally blocks. Older clients may ignore the fields; do not claim enforcement for them. Turning off publication does not remove an already published policy.
|
||||
|
||||
## Interaction With Provider Configuration
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"providers": {
|
||||
"company-ai": {
|
||||
"endpoint": {
|
||||
"type": "openai/responses",
|
||||
"url": "https://ai.company.example/v1/responses",
|
||||
},
|
||||
"package": "@opencode/ai/providers/openai",
|
||||
"settings": { "baseURL": "https://ai.company.example/v1" },
|
||||
},
|
||||
},
|
||||
"experimental": {
|
||||
@@ -224,7 +257,7 @@ Provider policy is not a full sandbox for executable plugins. A denied provider
|
||||
}
|
||||
```
|
||||
|
||||
The provider entry configures `company-ai`; the policy statements make it the only provider permitted for use.
|
||||
The provider entry configures `company-ai`; the policy statements make that ID the only provider permitted for use. Credentials and models are still required. Local provider overlays may change an allowed ID's package, endpoint, or other transport fields; a provider-ID allowlist is not endpoint enforcement.
|
||||
|
||||
Provider policy applies regardless of how a provider becomes known or usable, including:
|
||||
|
||||
@@ -236,16 +269,19 @@ Provider policy applies regardless of how a provider becomes known or usable, in
|
||||
|
||||
## Applying Provider Policy
|
||||
|
||||
Provider records and model overrides are assembled before checking provider policy. Otherwise later provider loading could recreate a provider that was already filtered.
|
||||
Provider records and model overrides remain in the underlying catalog. Public reads check policy so later provider loading cannot bypass filtering, while a later valid allow can expose an otherwise usable provider again.
|
||||
|
||||
Flow:
|
||||
|
||||
1. Build provider/model catalog entries.
|
||||
2. Apply configured provider and model overrides.
|
||||
3. Run the terminal config policy transform.
|
||||
4. Remove providers denied by the final matching `provider.use` statement.
|
||||
3. Read one policy snapshot containing reversed authored rules followed by managed rules.
|
||||
4. Filter all public provider/model catalog views using the final decision.
|
||||
5. Assert policy again at model resolution, including resolution of a previously selected model value.
|
||||
|
||||
Config reload refreshes the plugin's policy snapshot and rebuilds the catalog.
|
||||
Config updates publish catalog-updated events. Managed refresh updates the policy snapshot before rebuilding catalog/search projections. Session errors distinguish denied/unavailable policy in their messages while using the existing error protocol.
|
||||
|
||||
The pure matcher and fixtures are duplicated in Console's shared package and this repository's util package. Console's `scripts/check-opencode-policy-conformance.ts` verifies byte-for-byte equality. Tests cover precedence, platform matching, catalog views, previously selected model resolution, loader protection, failure transitions, and identity changes. An opt-in paired integration test consumes a policy published through Console's real browser/API/database flow. These checks do not establish deployed fleet behavior or successful billable provider requests.
|
||||
|
||||
## Legacy Migration
|
||||
|
||||
|
||||
Reference in New Issue
Block a user