Compare commits

..
46 changed files with 661 additions and 560 deletions
-1
View File
@@ -353,7 +353,6 @@
"@ff-labs/fff-node": "0.10.5",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/pty": "0.1.13",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
"x86_64-linux": "sha256-E5T4o3wNivOg8q4wRV7yE4CUNaUq4QNPxZsnvLaGUcg=",
"aarch64-linux": "sha256-xQQi7LgxInZQVCznASfa0Pm+cNGBo5j4Tfp0E/bKnNw=",
"aarch64-darwin": "sha256-qxb371dCf7WG09VvQE+HZ/x3EURpFIr11bdnQwGTHhw=",
"x86_64-darwin": "sha256-xi1qiYr41hzbgAQl+I3xdtnkiPx2SIcA/0OMpN78qXw="
}
}
@@ -23,7 +23,6 @@ for (const custom of [false, true]) {
await page.goto(stressSessionHref(fixture.sourceID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(trigger).toBeEnabled()
await trigger.hover()
const tooltip = page.getByRole("tooltip")
@@ -278,11 +278,7 @@ async function installMotionProbe(page: Page) {
probe.resetAnchorOnMotion = false
}
probe.terminalAnchorGaps.push(anchorGap)
if (
panelGap &&
reviewRegion.getBoundingClientRect().height > 1 &&
terminalRegion.getBoundingClientRect().height > 1
)
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
if (!review) return
probe.paintGaps.push({
+2 -1
View File
@@ -33,7 +33,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
const errors: Array<Promise<string>> = []
let failure: unknown
try {
await fs.mkdir(path.join(root, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(root, ".opencode"))
spawnService()
spawnService()
const registration = await waitForRegistration()
@@ -54,6 +54,7 @@ try {
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
await fs.mkdir(path.dirname(plugin), { recursive: true })
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
@@ -1342,7 +1342,6 @@ export type ModelCompatibility = {
maxTokensField?: ModelMaxTokensField
requireFinishReason?: boolean
requireAssistantAfterTool?: boolean
supportsPromptCacheKey?: boolean
}
export type ProviderInfo = {
@@ -2012,7 +2011,6 @@ export type ConfigEntry =
scope?: string
callback_port?: number
redirect_uri?: string
auth_server_metadata_url?: string
}
| false
disabled?: boolean
@@ -4668,7 +4666,6 @@ export type McpAddInput = {
readonly scope?: string
readonly callback_port?: number
readonly redirect_uri?: string
readonly auth_server_metadata_url?: string
}
| false
readonly disabled?: boolean
+17 -15
View File
@@ -22,7 +22,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
what `JSON.stringify` would serialize: `toJSON` is honored, functions and `undefined` properties vanish,
`undefined` array elements and non-finite numbers become `null`, a cyclic value throws the same `TypeError`,
and Map, RegExp, and generators serialize as `{}`. A bare `undefined` result is `null`.
and Map, RegExp, generators, and extension handles serialize as `{}`. A bare `undefined` result is `null`.
Tool results come back the way `JSON.parse(JSON.stringify(result))` would. The table, where a value cannot
be JSON but what the program meant is clear: a promise is awaited (a rejection fails the program), a Set
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own }`, a
@@ -192,7 +192,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Prefix and postfix `++` and `--`.
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index
creates a hole without changing its length. Deleting a non-configurable property (`length`) or
creates a hole without changing its length. Deleting a non-configurable property (`length`, `lastIndex`) or
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode.
- [ ] Operators, `switch` discriminants, template interpolation, and coercion helpers such as `String` and `isNaN`
applied to functions and namespaces; JavaScript coerces them, the interpreter rejects non-data operands.
@@ -394,9 +394,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
`unicodeSets`, and `dotAll`.
- [x] Captures, named groups, match `.index` and `.input`, and stateful global matching.
- [x] Integration with supported String methods, including function replacers.
- [x] Writable `lastIndex`, shared by `exec`, `test`, and the String methods. It is a prototype accessor that stores
a number, so `re.lastIndex = "12"` reads back `12`, `delete` is a no-op, and `hasOwnProperty("lastIndex")` is
`false`.
- [x] Writable `lastIndex`.
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
- [x] `RegExp.escape`.
@@ -454,25 +452,29 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
## Extensions
Host functions a host opts in through `Extension.make({ name, globals })` and `CodeMode.make({ extensions })`.
Host classes and functions a host opts in through `Extension.make({ name, globals })` and `CodeMode.make({ extensions })`.
Nothing is exposed unless a host provides it; extension calls are not tool calls.
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
a built-in or another extension throws at `make`.
- [x] Each global is a class or a function, exposed as-is: constructors with `new`, prototype methods, accessors,
and statics (including through an exposed subclass, so `new this()` works), plus inheritance
up to the nearest exposed ancestor. A global that shadows a built-in or another extension throws at `make`.
- [x] Instances of exposed classes stay on the host; the program holds a handle whose only members are the class's.
The same host instance is always the same handle within a run, so identity and `instanceof` hold. A handle
serializes as `{}` like any object without enumerable properties, so the host object never crosses.
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
(`fetch.json`). Like any program function it vanishes at the data boundary.
un-awaited promises, and symbols cannot be passed in; an instance of an unexposed class, a symbol, or a BigInt
cannot come out.
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
plain data).
plain data). A getter must be synchronous.
- [x] A prototype member runs only with a handle of its own class as `this`; a detached call, a plain object, or a
handle of another class throws `TypeError: Illegal invocation`. Program edits to an exposed prototype affect
that run only. Data properties on a class or prototype are not exposed, since a program write would change the
host class itself; expose one through an accessor.
- [ ] Program functions as arguments to extension code (callbacks such as `forEach`).
- [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next
step if `new X()` in a program is ever needed.
## Errors and diagnostics
+1 -1
View File
@@ -37,7 +37,7 @@ export type ResolvedExecutionLimits = {
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
/** Explicit tools exposed to the program as `tools`. */
tools?: Provided & Tools<Services<Provided>>
/** Host functions exposed as globals; see `Extension.make`. */
/** Host classes and functions exposed as globals; see `Extension.make`. */
extensions?: ReadonlyArray<Extension>
/** Resource limits enforced on each execution. */
limits?: ExecutionLimits
+5 -4
View File
@@ -1,19 +1,20 @@
export * as Extension from "./extension.js"
/**
* Host functions a program calls directly as globals. Values crossing in either direction are converted, never
* shared: arguments come in as copies, results go out as copies, and a function inside a result is callable the
* same way. Extension calls are not tool calls.
* Host classes and functions a program uses directly, like JavaScript. Values crossing in either direction are
* converted, never shared: plain data is copied, instances of the classes stay on the host behind program-side
* handles. Extension calls are not tool calls.
*/
export type Extension = {
readonly name: string
/** Each value is a class or a function; everything on a class is exposed, including statics and accessors. */
readonly globals: Readonly<Record<string, Function>>
}
export const make = (options: Extension): Extension => {
for (const [name, value] of Object.entries(options.globals)) {
if (typeof value !== "function") {
throw new TypeError(`Extension "${options.name}" global "${name}" must be a function.`)
throw new TypeError(`Extension "${options.name}" global "${name}" must be a class or a function.`)
}
}
return { name: options.name, globals: { ...options.globals } }
+178 -38
View File
@@ -5,18 +5,21 @@ import type { Interpreter } from "./interpreter.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
import { MAX_VALUE_DEPTH } from "./limits.js"
import { Throw, typeError } from "./model.js"
import { fn } from "./native.js"
import { constructor, fn } from "./native.js"
import {
Callable,
define,
defineAccessor,
entries,
get,
hidden,
type Native,
Arr,
Bytes,
DateObj,
ErrorObj,
GeneratorObj,
Handle,
MapObj,
Obj,
PromiseObj,
@@ -27,16 +30,31 @@ import {
} from "./objects.js"
import { describeValue } from "./references.js"
type Class = Function & { readonly prototype: object }
const isClass = (value: unknown): value is Class =>
typeof value === "function" && typeof value.prototype === "object" && value.prototype !== null
// Own keys the native function already carries.
const ownFunctionKeys = new Set(["length", "name", "prototype"])
const ownPrototypeKeys = new Set(["constructor"])
/**
* The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data and
* built-in wrappers are copied, a host function becomes a program function whose calls cross the same way, and a
* host Promise becomes a program promise.
* The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data is
* copied, built-in wrappers are copied, instances of exposed classes travel as handles, and a host Promise becomes
* a program promise. Prototypes, constructors, and handle identity are all per run.
*/
export const extensionGlobals = <R>(
ctx: Interpreter<R>,
extensions: ReadonlyArray<Extension>,
): ReadonlyArray<readonly [string, unknown]> => {
const builtins = ctx.builtins
const classes = new Set(extensions.flatMap((extension) => Object.values(extension.globals)).filter(isClass))
// Host prototype object → this run's program prototype, so an instance wraps as its most-derived exposed class.
const protoOf = new Map<object, Obj>()
const exposed = new Map<Class, { ctor: Native<R>; proto: Obj }>()
const classOf = new Map<unknown, Class>()
const handles = new WeakMap<object, Handle>()
const toHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
@@ -44,6 +62,7 @@ export const extensionGlobals = <R>(
if (isPrimitive(value)) return value
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
}
if (value instanceof Handle) return value.instance
if (value instanceof Bytes) return new Uint8Array(value.bytes)
if (value instanceof DateObj) return new Date(value.time)
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
@@ -64,7 +83,7 @@ export const extensionGlobals = <R>(
const name = coerceToString(get(value, "name"))
const message = get(value, "message")
const text = message === undefined ? "" : coerceToString(message)
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors[name] ?? Error)(text)
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
@@ -83,8 +102,15 @@ export const extensionGlobals = <R>(
const fromHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
if (isPrimitive(value)) return value
if (typeof value === "function") return wrap(value, label)
if (value !== null && typeof value === "object") {
const existing = handles.get(value)
if (existing !== undefined) return existing
const proto = handlePrototype(value)
if (proto !== undefined) {
const handle = new Handle(proto, value)
handles.set(value, handle)
return handle
}
if (value instanceof Date) return new DateObj(builtins.Date, value.getTime())
if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags)
if (value instanceof Uint8Array) return new Bytes(builtins.Uint8Array, new Uint8Array(value))
@@ -96,31 +122,28 @@ export const extensionGlobals = <R>(
if (value instanceof URLSearchParams) {
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
}
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
const next = (item: unknown) => fromHost(item, label, depth + 1, seen)
if (value instanceof Map) {
const wrapped = new MapObj(builtins.Map)
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
for (const [key, item] of value) wrapped.map.set(next(key), next(item))
return wrapped
}
if (value instanceof Set) {
const wrapped = new SetObj(builtins.Set)
for (const item of value) wrapped.set.add(next(item, label))
for (const item of value) wrapped.set.add(next(item))
return wrapped
}
if (seen.has(value)) throw typeError(`${label} produced a circular value.`)
seen.add(value)
if (Array.isArray(value)) {
const copied = new Arr(
builtins.Array,
value.map((item, index) => next(item, `${label}[${index}]`)),
)
const copied = new Arr(builtins.Array, value.map(next))
seen.delete(value)
return copied
}
const prototype = Object.getPrototypeOf(value)
if (prototype === Object.prototype || prototype === null) {
const copied = new Obj(builtins.Object)
for (const [key, item] of Object.entries(value)) define(copied, key, next(item, `${label}.${key}`))
for (const [key, item] of Object.entries(value)) define(copied, key, next(item))
seen.delete(value)
return copied
}
@@ -128,37 +151,153 @@ export const extensionGlobals = <R>(
throw typeError(`${label} produced ${describeHost(value)}, which the program cannot hold.`)
}
// A host function as a program function: arguments cross in, and whatever it returns, resolves, throws, or
// rejects with crosses out, so the program catches what the author threw.
const wrap = (value: Function, label: string): Native<R> =>
fn<R>(builtins, value.name, value.length, (_, values) => {
const converted = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
const thrown = (reason: unknown) => new Throw(fromHost(reason, label))
let result: unknown
try {
result = value.apply(undefined, converted)
} catch (reason) {
return Effect.fail(thrown(reason))
const handlePrototype = (instance: object): Obj | undefined => {
for (let level = Object.getPrototypeOf(instance); level !== null; level = Object.getPrototypeOf(level)) {
const proto = protoOf.get(level)
if (proto !== undefined) return proto
}
return undefined
}
// Runs host code with already-converted inputs. Whatever it returns, resolves, throws, or rejects with crosses
// the same way, so the program catches what the author threw.
const invoke = (run: () => unknown, label: string): Effect.Effect<unknown, unknown, R> => {
const thrown = (reason: unknown) => new Throw(fromHost(reason, label))
let result: unknown
try {
result = run()
} catch (reason) {
return Effect.fail(thrown(reason))
}
if (!(result instanceof Promise)) return Effect.succeed(fromHost(result, label))
return ctx.pending.create(
Effect.map(Effect.tryPromise({ try: () => result, catch: thrown }), (settled) => fromHost(settled, label)),
)
}
const args = (values: Array<unknown>, label: string): Array<unknown> =>
values.map((value, index) => toHost(value, `Argument ${index + 1} to ${label}`))
// Own members of each level from `from` up to (excluding) `root`, child first, as JS resolves them.
const members = (
target: Obj,
from: object,
root: object,
skip: ReadonlySet<string>,
label: string,
receiver: (thisValue: unknown, member: string) => unknown,
): void => {
for (let level: object | null = from; level !== null && level !== root; level = Object.getPrototypeOf(level)) {
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(level))) {
if (skip.has(key) || target.props.has(key)) continue
const name = `${label}.${key}`
if (typeof descriptor.value === "function") {
const method: Function = descriptor.value
const impl = (thisValue: unknown, values: Array<unknown>) => {
const self = receiver(thisValue, name)
const converted = args(values, name)
return invoke(() => method.apply(self, converted), name)
}
define(target, key, fn<R>(builtins, key, method.length, impl), hidden)
continue
}
// Data properties stay host-side: a program write to one would change the host class itself.
if ("value" in descriptor) continue
const get = descriptor.get
const set = descriptor.set
defineAccessor(
target,
key,
get === undefined
? undefined
: (thisValue) => {
const value = get.call(receiver(thisValue, name))
if (value instanceof Promise)
throw typeError(`${name} returned a Promise; a getter must be synchronous.`)
return fromHost(value, name)
},
set === undefined
? undefined
: (thisValue, value) => {
set.call(receiver(thisValue, name), toHost(value, `${name} value`))
},
)
}
if (!(result instanceof Promise)) return fromHost(result, label)
return ctx.pending.create(
Effect.map(Effect.tryPromise({ try: () => result, catch: thrown }), (settled) => fromHost(settled, label)),
)
}
}
const expose = (cls: Class): { ctor: Native<R>; proto: Obj } => {
const existing = exposed.get(cls)
if (existing !== undefined) return existing
const ancestor = exposedAncestor(cls)
const base = ancestor === undefined ? undefined : expose(ancestor)
const proto = new Obj(base === undefined ? builtins.Object : base.proto)
protoOf.set(cls.prototype, proto)
const name = cls.name
const ctor = constructor<R>(builtins, proto, {
name,
length: cls.length,
call: (_, values) => {
const converted = args(values, name)
return invoke(() => cls.apply(undefined, converted), name)
},
construct: (values) => {
const label = `new ${name}`
const construct = cls as new (...values: Array<unknown>) => object
const converted = args(values, label)
return invoke(() => new construct(...converted), label)
},
})
if (base !== undefined) ctor.proto = base.ctor
const entry = { ctor, proto }
exposed.set(cls, entry)
classOf.set(ctor, cls)
// A static called through an exposed subclass sees that subclass as `this`, like JS.
members(ctor, cls, ancestor ?? Function.prototype, ownFunctionKeys, name, (thisValue) => {
const called = classOf.get(thisValue)
return called !== undefined && (called === cls || called.prototype instanceof cls) ? called : cls
})
members(
proto,
cls.prototype,
ancestor?.prototype ?? Object.prototype,
ownPrototypeKeys,
`${name}.prototype`,
(thisValue, member) => {
if (thisValue instanceof Handle && thisValue.instance instanceof cls) return thisValue.instance
throw typeError(`Illegal invocation: ${member} called on ${describeValue(thisValue)}.`)
},
)
return entry
}
const exposedAncestor = (cls: Class): Class | undefined => {
for (let level = Object.getPrototypeOf(cls); isClass(level); level = Object.getPrototypeOf(level)) {
if (classes.has(level)) return level
}
return undefined
}
return extensions.flatMap((extension) =>
Object.entries(extension.globals).map(([name, value]) => [name, wrap(value, name)] as const),
Object.entries(extension.globals).map(([name, value]) => {
if (isClass(value)) return [name, expose(value).ctor] as const
const impl = (_: unknown, values: Array<unknown>) => {
const converted = args(values, name)
return invoke(() => value.apply(undefined, converted), name)
}
return [name, fn<R>(builtins, name, value.length, impl)] as const
}),
)
}
const hostErrors = new Map<string, ErrorConstructor>([
["TypeError", TypeError],
["RangeError", RangeError],
["SyntaxError", SyntaxError],
["ReferenceError", ReferenceError],
["EvalError", EvalError],
["URIError", URIError],
])
const hostErrors: Record<string, ErrorConstructor | undefined> = {
TypeError,
RangeError,
SyntaxError,
ReferenceError,
EvalError,
URIError,
}
// The primitives the interpreter operates on; symbols and BigInts are not among them.
const isPrimitive = (value: unknown): boolean =>
@@ -169,6 +308,7 @@ const isPrimitive = (value: unknown): boolean =>
typeof value === "boolean"
const describeHost = (value: unknown): string => {
if (typeof value === "function") return "a function"
if (typeof value !== "object" || value === null) return `a ${typeof value}`
const name = (value as { constructor?: { name?: string } }).constructor?.name
return name === undefined || name === "" ? "an object" : `a ${name}`
@@ -136,6 +136,7 @@ export class RegExpObj extends Obj {
constructor(proto: Obj, pattern: string, flags: string) {
super(proto)
this.regex = new RegExp(pattern, flags)
define(this, "lastIndex", 0, { writable: true, enumerable: false, configurable: false })
}
}
@@ -178,6 +179,16 @@ export class Bytes extends Obj {
}
}
/** An instance of an extension class: the host object lives in a field no property path reaches. */
export class Handle extends Obj {
constructor(
proto: Obj,
readonly instance: object,
) {
super(proto)
}
}
/** Built-in objects that wrap a host value; data-like, but never plain data. */
export const isWrapper = (
value: unknown,
@@ -9,6 +9,7 @@ import {
Bytes,
DateObj,
GeneratorObj,
Handle,
MapObj,
Obj,
PromiseObj,
@@ -22,6 +23,7 @@ import {
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof Callable ||
value instanceof GeneratorObj ||
value instanceof Handle ||
value instanceof ToolReference ||
value instanceof PromiseObj ||
isWrapper(value)
@@ -87,6 +89,7 @@ export const describeValue = (value: unknown): string => {
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
if (value instanceof Bytes) return "a Uint8Array"
if (value instanceof GeneratorObj) return "a generator"
if (value instanceof Handle) return `a ${value.instance.constructor.name}`
if (isRuntimeReference(value)) return "a function"
if (typeof value === "object") return "a data object"
return `a ${typeof value}`
+13 -11
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import type { Builtins } from "../interpreter/intrinsics.js"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { syntaxError, typeError } from "../interpreter/model.js"
import { define, defineAccessor, Arr, Obj, RegExpObj, record } from "../interpreter/objects.js"
import { define, defineAccessor, getOwn, Arr, Obj, RegExpObj, record, set } from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToNumber, coerceToString } from "./value.js"
@@ -75,6 +75,12 @@ export const constructRegExp = (builtins: Builtins, args: Array<unknown>, proto:
}
}
const toLength = (value: unknown): number => {
const number = coerceToNumber(value)
if (Number.isNaN(number) || number <= 0) return 0
return Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER)
}
// RegExp constructs identically with or without new, like JS.
export const regexpGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
@@ -99,22 +105,18 @@ export const regexpGlobal = <R>(ctx: Interpreter<R>) => {
const self = (thisValue: unknown, name: string) => receiver(RegExpObj, thisValue, `RegExp.prototype.${name}`)
defineAccessor(proto, "source", (thisValue) => self(thisValue, "source").regex.source)
defineAccessor(proto, "flags", (thisValue) => self(thisValue, "flags").regex.flags)
// The host regex holds the only lastIndex, so exec/test and the String methods share one counter.
defineAccessor(
proto,
"lastIndex",
(thisValue) => self(thisValue, "lastIndex").regex.lastIndex,
(thisValue, value) => {
self(thisValue, "lastIndex").regex.lastIndex = coerceToNumber(value)
},
)
for (const name of flagProperties) defineAccessor(proto, name, (thisValue) => self(thisValue, name).regex[name])
// exec/test run the host regex from the program-visible lastIndex and write it back only when g or y is set.
const run = (name: "exec" | "test"): Method => [
name,
1,
(thisValue, args) => {
const value = self(thisValue, name)
const matched = value.regex.exec(coerceToString(args[0]))
const input = coerceToString(args[0])
const stateful = value.regex.global || value.regex.sticky
value.regex.lastIndex = toLength(getOwn(value, "lastIndex"))
const matched = value.regex.exec(input)
if (stateful) set(value, "lastIndex", value.regex.lastIndex)
if (name === "test") return matched !== null
return matched === null ? null : matchToValue(builtins, matched)
},
+175 -131
View File
@@ -2,12 +2,67 @@ import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Extension, Tool } from "../src/index.js"
class Bag {
static made = 0
static of(...items: Array<string>) {
return new this(items)
}
constructor(readonly items: Array<string> = []) {
Bag.made++
}
get size() {
return this.items.length
}
set size(length: number) {
this.items.length = length
}
add(item: string) {
this.items.push(item)
return this
}
toArray() {
return [...this.items]
}
pair() {
return { self: this, list: [this, new Bag()] }
}
async later<T>(value: T) {
return value
}
async reject(reason: unknown) {
throw reason
}
fail() {
throw new RangeError("boom")
}
get lazy() {
return Promise.resolve(1)
}
detached() {
return new Other()
}
}
class Other {}
class Big extends Bag {
double() {
return this.items.length * 2
}
}
class Vault {
secrets = new Map<string, string>()
set(key: string, value: string) {
this.secrets.set(key, value)
}
}
const held: Array<unknown> = []
const config = { retries: 3, nested: { deep: true } }
const requests: Array<unknown> = []
const extension = Extension.make({
name: "web",
name: "bag",
globals: {
Bag,
Big,
Vault,
keep: (value: unknown) => {
held.push(value)
return value
@@ -15,19 +70,6 @@ const extension = Extension.make({
settings: () => config,
later: async (value: number) => value + 1,
first: (map: Map<unknown, unknown>) => map.get("k"),
fetch: async (url: string, init?: { method?: string }) => {
requests.push([url, init])
const bytes = new TextEncoder().encode(`{"url":"${url}"}`)
return {
status: 200,
ok: true,
headers: { get: (name: string) => (name === "content-type" ? "application/json" : null) },
text: () => new TextDecoder().decode(bytes),
json: () => JSON.parse(new TextDecoder().decode(bytes)),
bytes: () => bytes,
handlers: [(step: number) => step + 1],
}
},
},
})
@@ -45,61 +87,66 @@ const failure = async (code: string, target = runtime) => {
return result.error
}
describe("extension functions", () => {
test("a global is callable, awaitable, and not constructible", async () => {
describe("extension classes behave like JS", () => {
test("construct, call methods, read and write accessors", async () => {
expect(await value(`const b = new Bag(["a"]); b.add("b"); return [b.size, b.toArray()]`)).toEqual([2, ["a", "b"]])
expect(await value(`const b = new Bag(["a", "b"]); b.size = 1; return b.toArray()`)).toEqual(["a"])
})
test("instanceof, constructor, typeof, and prototype identity", async () => {
expect(
await value(
`const b = new Bag(); return [b instanceof Bag, b.constructor === Bag, typeof Bag, Bag.prototype.constructor === Bag]`,
),
).toEqual([true, true, "function", true])
})
test("statics, including `new this()` through an exposed subclass", async () => {
expect(await value(`return [Bag.of("x", "y").toArray(), Big.of("q") instanceof Big, Big.of === Bag.of]`)).toEqual([
["x", "y"],
true,
true,
])
})
test("data properties are invisible, so a program write never reaches the host class", async () => {
Bag.made = 0
expect(await value(`Bag.made = 999; return Bag.made`)).toBe(999)
expect(Bag.made).toBe(0)
expect(await value(`return [Bag.made, new Bag(["a"]).items]`)).toEqual([null, null])
})
test("inheritance chains to the exposed ancestor", async () => {
expect(
await value(`const b = new Big(["a"]); return [b.double(), b.add("b").size, b instanceof Bag, b instanceof Big]`),
).toEqual([2, 2, true, true])
})
test("calling a class without new throws the host TypeError", async () => {
const error = await failure(`Bag()`)
expect(error.message).toStartWith("TypeError: ")
expect(error.message).toContain("new")
})
test("a function global is callable, awaitable, and not constructible", async () => {
expect(await value(`return await later(1)`)).toBe(2)
expect(await value(`return [typeof later, later.name, later.length]`)).toEqual(["function", "later", 1])
expect((await failure(`new later()`)).message).toContain("new later(...) is not supported")
})
test("a function inside a result is callable and crosses the same way", async () => {
requests.length = 0
expect(
await value(
`const res = await fetch("https://a.test/", { method: "GET" }); return [res.status, res.ok, res.headers.get("content-type"), res.text(), res.json(), [...res.bytes()].length, typeof res.json, res.json.name, res.handlers[0](1)]`,
),
).toEqual([
200,
true,
"application/json",
'{"url":"https://a.test/"}',
{ url: "https://a.test/" },
25,
"function",
"json",
2,
])
expect(requests).toEqual([["https://a.test/", { method: "GET" }]])
})
test("a function inside a result is named by its path in diagnostics", async () => {
expect((await failure(`const res = await fetch("https://a.test/"); res.handlers[0](() => 1)`)).message).toContain(
"Argument 1 to fetch.handlers[0] contains a function",
)
const target = CodeMode.make({
extensions: [Extension.make({ name: "odd", globals: { make: () => ({ sym: () => Symbol("s") }) } })],
})
expect((await failure(`make().sym()`, target)).message).toContain("make.sym produced a symbol")
})
test("a function is invisible to the data boundary like any program function", async () => {
expect(await value(`return await fetch("https://a.test/")`)).toEqual({
status: 200,
ok: true,
headers: {},
handlers: [null],
})
expect(await value(`return JSON.stringify((await fetch("https://a.test/")).headers)`)).toBe("{}")
})
test("a class global is only a function; calling it throws the host TypeError", async () => {
const target = CodeMode.make({ extensions: [Extension.make({ name: "cls", globals: { Bag: class Bag {} } })] })
expect((await failure(`Bag()`, target)).message).toContain("without")
expect((await failure(`new Bag()`, target)).message).toContain("new Bag(...) is not supported")
test("a program can patch a prototype for its own run only", async () => {
expect(await value(`Bag.prototype.add = () => "patched"; return new Bag().add("x")`)).toBe("patched")
expect(await value(`return new Bag().add("x").toArray()`)).toEqual(["x"])
})
})
describe("values are converted at the boundary, never shared", () => {
test("the same host instance is the same handle", async () => {
expect(
await value(`const b = new Bag(); const p = b.pair(); return [p.self === b, p.list[0] === b, keep(b) === b]`),
).toEqual([true, true, true])
expect(await value(`const p = new Bag().pair(); return p.list[1] instanceof Bag`)).toBe(true)
})
test("plain data passed in is a copy the program cannot change afterwards", async () => {
held.length = 0
await value(
@@ -116,12 +163,8 @@ describe("values are converted at the boundary, never shared", () => {
expect(config).toEqual({ retries: 3, nested: { deep: true } })
})
test("the same host value returned twice is two program values", async () => {
expect(await value(`return settings() === settings()`)).toBe(false)
expect(await value(`return keep(settings()) === settings()`)).toBe(false)
})
test("Map and Set contents are converted element-wise", async () => {
test("Map and Set contents are converted element-wise, so handles unwrap inside them", async () => {
expect(await value(`const b = new Bag(); return first(new Map([["k", b]])) === b`)).toBe(true)
expect(await value(`return first(new Map([["k", { z: 1 }]]))`)).toEqual({ z: 1 })
held.length = 0
await value(`const inner = { z: 1 }; keep(new Set([inner])); inner.z = 2`)
@@ -171,13 +214,6 @@ describe("values are converted at the boundary, never shared", () => {
expect(Object.keys(held[0] as object)).toEqual([])
})
test("an Error with an unknown name crosses as a plain Error", async () => {
held.length = 0
await value(`const e = new Error("x"); e.name = "constructor"; keep(e); e.name = "__proto__"; keep(e)`)
expect(held[0]).toBeInstanceOf(Error)
expect(held[1]).toBeInstanceOf(Error)
})
test("functions, promises, and symbols cannot be passed in", async () => {
expect((await failure(`keep(() => 1)`)).message).toContain("Argument 1 to keep contains a function")
expect((await failure(`keep(later(1))`)).message).toContain("un-awaited Promise")
@@ -192,45 +228,82 @@ describe("values are converted at the boundary, never shared", () => {
expect((await failure(`big()`, target)).message).toContain("big produced a bigint")
})
test("a class instance cannot come out", async () => {
class Other {}
const target = CodeMode.make({
extensions: [Extension.make({ name: "odd", globals: { detached: () => new Other() } })],
test("an instance of an unexposed class cannot come out", async () => {
expect((await failure(`new Bag().detached()`)).message).toContain("produced a Other, which the program cannot hold")
})
test("a getter must be synchronous", async () => {
expect((await failure(`new Bag().lazy`)).message).toContain("Bag.prototype.lazy returned a Promise")
})
})
describe("the host object behind a handle is unreachable", () => {
test("enumeration, spread, and JSON see no own properties", async () => {
expect(
await value(`const b = new Bag(["a"]); return [Object.keys(b), Object.entries({ ...b }), String(b)]`),
).toEqual([[], [], "[object Object]"])
})
test("a handle serializes as {} when returned, stringified, or handed to a tool", async () => {
expect(await value(`return new Bag()`)).toEqual({})
expect(await value(`return JSON.stringify(new Bag())`)).toBe("{}")
const tools = CodeMode.make({
extensions: [extension],
tools: {
echo: Tool.make({
description: "Echo",
input: Schema.Struct({ v: Schema.Unknown }),
output: Schema.Unknown,
execute: (input) => Effect.succeed(input.v),
}),
},
})
expect((await failure(`detached()`, target)).message).toContain("produced a Other, which the program cannot hold")
expect(await value(`return await tools.echo({ v: new Bag() })`, tools)).toEqual({})
})
test("a method only runs on a handle of its own class", async () => {
expect((await failure(`const add = new Bag().add; add("x")`)).message).toContain(
"Illegal invocation: Bag.prototype.add called on undefined",
)
expect((await failure(`const o = { add: Bag.prototype.add }; o.add("x")`)).message).toContain(
"called on a data object",
)
const vault = new Vault()
const target = CodeMode.make({
extensions: [Extension.make({ name: "vault", globals: { Bag, Vault, vault: () => vault } })],
})
expect((await failure(`const v = vault(); v.add = Bag.prototype.add; v.add("x")`, target)).message).toContain(
"Illegal invocation: Bag.prototype.add called on a Vault",
)
expect(vault.secrets.size).toBe(0)
})
test("reading an accessor off the prototype itself is an illegal invocation", async () => {
expect((await failure(`Bag.prototype.size`)).message).toContain("Illegal invocation")
})
})
describe("host errors", () => {
test("a synchronous throw becomes the matching program error", async () => {
const target = CodeMode.make({
extensions: [
Extension.make({
name: "odd",
globals: {
fail: () => {
throw new RangeError("boom")
},
},
}),
],
})
expect(await value(`try { fail() } catch (e) { return [e instanceof RangeError, e.message] }`, target)).toEqual([
expect(await value(`try { new Bag().fail() } catch (e) { return [e instanceof RangeError, e.message] }`)).toEqual([
true,
"boom",
])
})
test("a thrown or rejected value crosses like a return, so the program catches what was thrown", async () => {
expect(
await value(
`try { await new Bag().reject(new TypeError("bad")) } catch (e) { return [e instanceof TypeError, e.message] }`,
),
).toEqual([true, "bad"])
expect(await value(`try { await new Bag().reject("plain") } catch (e) { return e }`)).toBe("plain")
const reason = { status: 404, nested: { a: 1 } }
const target = CodeMode.make({
extensions: [
Extension.make({
name: "api",
globals: {
reject: async (reason: unknown) => {
throw reason
},
get: async () => Promise.reject(reason),
boom: () => {
throw reason
@@ -239,13 +312,6 @@ describe("host errors", () => {
}),
],
})
expect(
await value(
`try { await reject(new TypeError("bad")) } catch (e) { return [e instanceof TypeError, e.message] }`,
target,
),
).toEqual([true, "bad"])
expect(await value(`try { await reject("plain") } catch (e) { return e }`, target)).toBe("plain")
expect(await value(`try { await get() } catch (e) { e.status = 0; return e }`, target)).toEqual({
status: 0,
nested: { a: 1 },
@@ -259,45 +325,23 @@ describe("host errors", () => {
describe("configuration", () => {
test("extension calls are not tool calls", async () => {
const limited = CodeMode.make({ extensions: [extension], limits: { maxToolCalls: 0 } })
const result = await Effect.runPromise(
limited.execute(`(await fetch("https://a.test/")).json(); return await later(1)`),
)
const result = await Effect.runPromise(limited.execute(`new Bag().add("x"); return await later(1)`))
expect(result.ok).toBe(true)
expect(result.toolCalls).toEqual([])
})
test("a result handed to a tool is plain data", async () => {
const tools = CodeMode.make({
extensions: [extension],
tools: {
echo: Tool.make({
description: "Echo",
input: Schema.Struct({ v: Schema.Unknown }),
output: Schema.Unknown,
execute: (input) => Effect.succeed(input.v),
}),
},
})
expect(await value(`return await tools.echo({ v: await fetch("https://a.test/") })`, tools)).toEqual({
status: 200,
ok: true,
headers: {},
handlers: [null],
})
})
test("a global must be a function", () => {
test("a global must be a class or a function", () => {
expect(() => Extension.make({ name: "bad", globals: { n: 1 as never } })).toThrow(
'Extension "bad" global "n" must be a function.',
'Extension "bad" global "n" must be a class or a function.',
)
})
test("a global may not shadow a built-in or another extension", () => {
expect(() => CodeMode.make({ extensions: [Extension.make({ name: "web", globals: { URL: () => 1 } })] })).toThrow(
expect(() => CodeMode.make({ extensions: [Extension.make({ name: "web", globals: { URL: class {} } })] })).toThrow(
'Extension "web" global "URL" is already defined.',
)
expect(() =>
CodeMode.make({ extensions: [extension, Extension.make({ name: "again", globals: { fetch: () => 1 } })] }),
).toThrow('Extension "again" global "fetch" is already defined.')
CodeMode.make({ extensions: [extension, Extension.make({ name: "again", globals: { Bag: class {} } })] }),
).toThrow('Extension "again" global "Bag" is already defined.')
})
})
+18 -90
View File
@@ -237,7 +237,7 @@ describe("RegExp", () => {
).toEqual(["1", "22"])
})
test("lastIndex is writable and stores a number", async () => {
test("lastIndex is writable and exec coerces its stored value", async () => {
expect(
await value(`
const pattern = /(?:ab|cd)\\d?/g
@@ -247,112 +247,40 @@ describe("RegExp", () => {
pattern.lastIndex = 0
return [stored, match[0], match.index, pattern.lastIndex]
`),
).toEqual([[12, "number"], "ab4", 17, 0])
// lastIndex is a prototype accessor, so delete is a no-op rather than a TypeError.
expect(await value(`const re = /a/; return [delete re.lastIndex, re.lastIndex]`)).toEqual([true, 0])
).toEqual([["12", "string"], "ab4", 17, 0])
expect((await error(`delete /a/.lastIndex`)).message).toContain("Cannot delete property 'lastIndex'")
})
test("a non-numeric lastIndex runs from 0; non-global exec and test leave it alone", async () => {
test("exec coerces CodeMode data objects assigned to lastIndex", async () => {
expect(
await value(`
const pattern = /a/g
pattern.lastIndex = {}
const stored = pattern.lastIndex
const match = pattern.exec("ba")
pattern.lastIndex = 10
const missed = pattern.exec("a")
const plain = /a/
plain.lastIndex = 5
return [match.index, pattern.lastIndex, missed, plain.exec("ba").index, plain.test("ba"), plain.lastIndex]
return [stored, match.index, pattern.lastIndex, missed]
`),
).toEqual([1, 0, null, 1, true, 5])
).toEqual([{}, 1, 0, null])
})
test("String methods read and update lastIndex like exec", async () => {
test("non-global exec and test coerce and preserve lastIndex", async () => {
expect(
await value(`
const re = /a/y
re.exec("aa")
re.lastIndex = 0
return ["aa".replace(re, "b"), re.lastIndex]
`),
).toEqual(["ba", 1])
expect(
await value(`
const re = /a/g
re.exec("aaa")
return ["aaa".match(re), re.lastIndex]
`),
).toEqual([["a", "a", "a"], 0])
expect(
await value(`
const re = /a/g
re.lastIndex = 2
return ["aaa".replace(re, () => "b"), re.lastIndex]
`),
).toEqual(["bbb", 0])
expect(
await value(`
const re = /a/g
re.lastIndex = 2
return ["aaa".replaceAll(re, "b"), re.lastIndex]
`),
).toEqual(["bbb", 0])
expect(
await value(`
const re = /a/y
re.lastIndex = 1
const m = "baa".match(re)
return [m.index, re.lastIndex]
`),
).toEqual([1, 2])
})
const execPattern = /a/
const execIndex = {}
execPattern.lastIndex = execIndex
const match = execPattern.exec("ba")
test("split, search, and matchAll leave lastIndex unchanged like JS", async () => {
expect(
await value(`
const re = /a/y
re.lastIndex = 2
return ["banana".split(re), re.lastIndex]
`),
).toEqual([["b", "n", "n", ""], 2])
expect(
await value(`
const re = /a/g
re.lastIndex = 2
return ["banana".search(re), re.lastIndex]
`),
).toEqual([1, 2])
expect(
await value(`
const re = /a/y
re.lastIndex = 2
return ["banana".search(re), re.lastIndex]
`),
).toEqual([-1, 2])
expect(
await value(`
const re = /a/g
re.lastIndex = 2
return ["banana".matchAll(re).map((m) => m.index), re.lastIndex]
`),
).toEqual([[3, 5], 2])
expect(
await value(`
const re = /a/gy
re.lastIndex = 1
return ["banana".matchAll(re).map((m) => m.index), re.lastIndex]
`),
).toEqual([[1], 1])
})
const testPattern = /a/
const testIndex = {}
testPattern.lastIndex = testIndex
const matched = testPattern.test("ba")
test("String methods leave lastIndex untouched without g or y", async () => {
expect(
await value(`
const re = /a/
re.lastIndex = 5
return ["aaa".replace(re, "b"), "aaa".match(re).index, "aaa".split(re), "aaa".search(re), re.lastIndex]
return [match.index, execPattern.lastIndex === execIndex, matched, testPattern.lastIndex === testIndex]
`),
).toEqual(["baa", 0, ["", "", "", ""], 0, 5])
).toEqual([1, true, true, true])
})
test("an unmatched string pattern returns null", async () => {
-1
View File
@@ -111,7 +111,6 @@
"@ff-labs/fff-node": "0.10.5",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/pty": "0.1.13",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
+1 -7
View File
@@ -111,13 +111,7 @@ export class CodeRequiredError extends Schema.TaggedError<CodeRequiredError>()("
export class AuthorizationError extends Schema.TaggedError<AuthorizationError>()("Integration.Authorization", {
cause: Schema.Defect(),
}) {
override get message() {
const cause = this.cause
if (cause instanceof Error && cause.message) return cause.message
return "Authorization failed"
}
}
}) {}
export class AttemptNotFoundError extends Schema.TaggedError<AttemptNotFoundError>()("Integration.AttemptNotFound", {
integrationID: ID,
+4 -9
View File
@@ -142,14 +142,9 @@ function snapshot(job: Active): Info {
}
}
function errorText(cause: Cause.Cause<unknown>) {
const render = (error: Error): string => {
const message = error.message || error.name || "Unknown error"
if (!(error.cause instanceof Error)) return message
const detail = render(error.cause)
return detail === message || detail.startsWith(`${message}\n`) ? detail : `${message}\nCaused by: ${detail}`
}
return Cause.prettyErrors(cause).map(render).join("\n") || "Unknown error"
function errorText(error: unknown) {
if (error instanceof Error) return error.message
return String(error)
}
function incrementSession(input: Map<SessionSchema.ID, number>, sessionID: SessionSchema.ID) {
@@ -210,7 +205,7 @@ export const make = Effect.gen(function* () {
status,
completed_at,
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
...(Exit.isFailure(exit) ? { error: errorText(exit.cause) } : {}),
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
},
}
if (status !== "cancelled") yield* persistBackground(next)
+2 -28
View File
@@ -7,7 +7,6 @@ import {
discoverOAuthServerInfo,
extractWWWAuthenticateParams,
parseErrorResponse,
resourceUrlFromServerUrl,
UnauthorizedError,
type FetchLike,
type OAuthClientProvider,
@@ -15,7 +14,6 @@ import {
type StoredOAuthClientInformation,
type StoredOAuthTokens,
} from "@modelcontextprotocol/client"
import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema } from "@modelcontextprotocol/core"
import { Cause, Deferred, Effect } from "effect"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import { Credential } from "../credential.js"
@@ -101,25 +99,6 @@ export const loggedFetch = (fields: { readonly server: string; readonly director
return request
})
// A configured authorization server document stands in for RFC 9728 discovery: the SDK reuses this
// state instead of probing the resource server, whose well-known path may not exist.
export const configuredDiscovery = async (input: {
readonly config: typeof ConfigMCP.Remote.Type
readonly fetchFn: FetchLike
}): Promise<OAuthDiscoveryState | undefined> => {
const url = input.config.oauth ? input.config.oauth.auth_server_metadata_url : undefined
if (!url) return undefined
const response = await input.fetchFn(url, { headers: { accept: "application/json" } })
if (!response.ok) throw new Error(`HTTP ${response.status} trying to load OAuth authorization server metadata`)
const body = await response.json()
const metadata = OAuthMetadataSchema.safeParse(body).data ?? OpenIdProviderDiscoveryMetadataSchema.parse(body)
return {
authorizationServerUrl: metadata.issuer,
authorizationServerMetadata: metadata,
resourceMetadata: { resource: resourceUrlFromServerUrl(input.config.url).toString() },
}
}
export interface Store {
readonly tokens: () => Promise<StoredOAuthTokens | undefined>
readonly saveTokens: (tokens: StoredOAuthTokens) => Promise<void>
@@ -155,10 +134,7 @@ export const provider = (options: Options): OAuthClientProvider => {
let discovery: OAuthDiscoveryState | undefined = options.discovery
return {
redirectUrl,
discoveryState: async () => {
discovery ??= await configuredDiscovery({ config: options.config, fetchFn: send })
return discovery
},
discoveryState: () => discovery,
saveDiscoveryState: (state) => {
discovery = state
},
@@ -412,9 +388,7 @@ export const authorize = (input: {
// CIMD needs the server to advertise it and accept public clients, and our published document only
// lists the loopback redirect; a configured client_id always wins.
const discovery = yield* Effect.tryPromise({
try: async () =>
(await configuredDiscovery({ config: input.config, fetchFn })) ??
discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
try: () => discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
const cimd =
+30
View File
@@ -3,6 +3,7 @@ export * as OpenCodeTools from "./opencode.js"
import { SystemPart, ToolFailure } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Model } from "@opencode/schema/model"
import { AbsolutePath } from "@opencode/schema/schema"
import { Session } from "@opencode/schema/session"
import { Effect, Schema } from "effect"
@@ -23,6 +24,14 @@ export const MoveInput = Schema.Struct({
const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath })
export const ModelsInput = Schema.Struct({
provider: Schema.optionalKey(Schema.String).annotate({
description: 'Only list models from this provider, for example "anthropic".',
}),
})
const ModelsOutput = Schema.Struct({ models: Schema.Array(Model.Info) })
export const Plugin = {
id: "opencode.tools",
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
@@ -85,6 +94,27 @@ export const Plugin = {
),
),
})
draft.add({
name: "models",
description:
'List the models available to you. Reference one as "provider/model", or "provider/model#variant" using an entry from its variants. Pass the reference anywhere a model is accepted, such as the subagent tool.',
input: ModelsInput,
output: ModelsOutput,
options: { namespace: "opencode", codemode: true },
execute: (input) =>
ctx.model.list().pipe(
Effect.map((list) => {
const models = list.data.filter(
(model) => input.provider === undefined || model.providerID === input.provider,
)
return {
output: { models },
content: models.map((model) => `${model.providerID}/${model.id}: ${model.name}`).join("\n"),
}
}),
Effect.mapError((error) => new ToolFailure({ message: "Unable to list models", error })),
),
})
})
.pipe(Effect.orDie)
}),
+37 -12
View File
@@ -7,6 +7,7 @@ import { Effect, Schema } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { Job } from "../../job.js"
import { Model } from "../../model.js"
import { Permission } from "../../permission.js"
import { Session } from "../../session.js"
import { SessionSchema } from "../../session/schema.js"
@@ -29,6 +30,10 @@ export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
model: Schema.optionalKey(Schema.String).annotate({
description:
'Set only when the user explicitly requests a model, and add "#variant" only when they request a variant too. Format "provider/model" or "provider/model#variant". Omitted, the subagent uses the agent\'s configured model, or your own. Use the models tool to find the reference for a requested model.',
}),
sessionID: Schema.optionalKey(SessionSchema.ID).annotate({
description:
"Continue a specific previous subagent conversation by passing its sessionID. Calls without a sessionID start a new conversation.",
@@ -61,8 +66,29 @@ export const Plugin = {
const agents = yield* Agent.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
const models = yield* Model.Service
const subagents = yield* SubagentJob.make
const resolveModel = Effect.fn("SubagentTool.resolveModel")(function* (input: string) {
const ref = yield* Effect.try({
try: () => Model.Ref.parse(input),
catch: () =>
new ToolFailure({ message: `Invalid model "${input}". Use "provider/model" or "provider/model#variant".` }),
})
const model = (yield* models.available()).find(
(model) => model.providerID === ref.providerID && model.id === ref.id,
)
if (model === undefined)
return yield* new ToolFailure({
message: `Model "${ref.providerID}/${ref.id}" is not available. Use the models tool to see what is available.`,
})
if (ref.variant !== undefined && !model.variants.some((variant) => variant.id === ref.variant))
return yield* new ToolFailure({
message: `Variant "${ref.variant}" is not available for "${ref.providerID}/${ref.id}". Available: ${model.variants.map((variant) => variant.id).join(", ") || "none"}.`,
})
return ref
})
yield* ctx.tool
.transform((editor) =>
editor.add({
@@ -131,24 +157,23 @@ export const Plugin = {
return yield* new ToolFailure({
message: `Session ${existing.id} is not a child of the current session`,
})
const override = input.model === undefined ? undefined : yield* resolveModel(input.model)
// Continuing with a different agent switches the child, mirroring create semantics
// where the agent's configured model wins over the inherited one.
if (existing !== undefined && existing.agent !== agent.id) {
yield* sessions.switchAgent({ sessionID: existing.id, agent: agent.id }).pipe(
Effect.andThen(
agent.model === undefined
? Effect.void
: sessions.switchModel({ sessionID: existing.id, model: agent.model }),
),
// where an explicit model wins over the agent's configured model, which wins over the inherited one.
if (existing !== undefined) {
const switched = existing.agent !== agent.id
const model = override ?? (switched ? agent.model : undefined)
yield* Effect.all([
switched ? sessions.switchAgent({ sessionID: existing.id, agent: agent.id }) : Effect.void,
model === undefined ? Effect.void : sessions.switchModel({ sessionID: existing.id, model }),
]).pipe(
Effect.mapError(
(error) =>
new ToolFailure({ message: `Failed to switch subagent session agent: ${existing.id}`, error }),
(error) => new ToolFailure({ message: `Failed to switch subagent session: ${existing.id}`, error }),
),
)
}
// Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model
const model = override ?? agent.model ?? parent.model
const child =
existing ??
(yield* sessions
+1 -14
View File
@@ -1,4 +1,4 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Credential } from "@opencode/core/credential"
@@ -674,16 +674,3 @@ describe("Integration", () => {
)
})
})
describe("AuthorizationError", () => {
test("reports the underlying cause message", () => {
expect(new Integration.AuthorizationError({ cause: new Error("Request failed: 401") }).message).toBe(
"Request failed: 401",
)
})
test("falls back when the cause carries no message", () => {
expect(new Integration.AuthorizationError({ cause: new Error() }).message).toBe("Authorization failed")
expect(new Integration.AuthorizationError({ cause: undefined }).message).toBe("Authorization failed")
})
})
+1 -26
View File
@@ -2,9 +2,8 @@ import { describe, expect } from "bun:test"
import { Job } from "@opencode/core/job"
import { KV } from "@opencode/core/kv"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Integration } from "@opencode/core/integration"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { SessionSchema } from "@opencode/core/session/schema"
import { testEffect } from "./lib/effect"
@@ -65,30 +64,6 @@ describe("Job", () => {
}),
)
it.live("preserves authorization and complete failure details without stacks", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const job = yield* jobs.start({
type: "test",
run: Effect.failCause(
Cause.combine(
Cause.fail(
new Integration.AuthorizationError({
cause: new Error("authorization failed", { cause: new Error("token expired") }),
}),
),
Cause.die({ code: "cleanup_failed" }),
),
),
})
expect((yield* jobs.wait({ id: job.id })).info).toMatchObject({
status: "error",
error: 'authorization failed\nCaused by: token expired\n{"code":"cleanup_failed"}',
})
}),
)
it.live("reuses running work when started again with the same ID", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
-32
View File
@@ -352,38 +352,6 @@ describe("MCP OAuth", () => {
expect(url.pathname).toBe("/as/authorize")
})
test("uses configured authorization server metadata when the resource publishes none", async () => {
const { server: issuer } = authorizationServer({})
const resource = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) })
const url = `${resource.url.origin}/mcp`
const oauth = {
client_id: "client",
auth_server_metadata_url: `${issuer.url.origin}/.well-known/oauth-authorization-server`,
}
const { url: authorization } = await Effect.runPromise(Effect.scoped(start(url, oauth)))
expect(authorization.origin).toBe(issuer.url.origin)
expect(authorization.pathname).toBe("/authorize")
expect(authorization.searchParams.get("resource")).toBe(url)
const { server, tokenRequests } = authorizationServer({})
const store = memoryCredentials([credential({ access: "expired", refresh: "refresh", url })])
const oauthProvider = await connectProvider(
new ConfigMCP.Remote({
type: "remote",
url,
oauth: { ...oauth, auth_server_metadata_url: `${server.url.origin}/.well-known/oauth-authorization-server` },
}),
store,
)
await auth(oauthProvider, { serverUrl: url }).finally(() => {
resource.stop(true)
issuer.stop(true)
server.stop(true)
})
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
})
test("forwards iss from the redirect so issuer-advertising servers can complete", async () => {
const { server } = authorizationServer({ authorization_response_iss_parameter_supported: true })
const result = await Effect.runPromise(
+54
View File
@@ -0,0 +1,54 @@
import { expect } from "bun:test"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { Provider } from "@opencode/core/provider"
import { Session } from "@opencode/core/session"
import { Tool } from "@opencode/core/tool"
import { OpenCodeTools } from "@opencode/core/tool/plugin/opencode"
import { Model } from "@opencode/schema/model"
import { Effect } from "effect"
import { testEffect } from "./lib/effect"
import { executeTool, toolIdentity } from "./lib/tool"
import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
it.effect("lists available models through the opencode namespace", () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
const plugins = yield* Plugin.Service
const pluginHost = yield* PluginHost.make(plugins)
yield* catalog.transform((editor) => {
editor.models.update(Provider.ID.make("test"), Model.ID.make("alpha"), (model) => {
model.name = "Alpha"
model.variants = [{ id: Model.VariantID.make("fast") }]
})
editor.models.update(Provider.ID.make("other"), Model.ID.make("beta"), (model) => {
model.name = "Beta"
})
editor.models.update(Provider.ID.make("other"), Model.ID.make("disabled"), (model) => {
model.enabled = false
})
})
yield* OpenCodeTools.Plugin.effect(pluginHost)
const registry = yield* Tool.Service
const run = (code: string) =>
executeTool(registry, {
sessionID: Session.ID.make("ses_tool_opencode"),
...toolIdentity,
call: { type: "tool-call", id: `call-${code.length}`, name: "execute", input: { code } },
})
const all = yield* run(
"const list = await tools.opencode.models({}); return list.models.map((model) => `${model.providerID}/${model.id}: ${model.name} [${model.variants.map((variant) => variant.id)}]`).sort()",
)
expect(all.content).toEqual([
{ type: "text", text: JSON.stringify(["other/beta: Beta []", "test/alpha: Alpha [fast]"], null, 2) },
])
const filtered = yield* run(
'const list = await tools.opencode.models({ provider: "other" }); return list.models.map((model) => model.id)',
)
expect(filtered.content).toEqual([{ type: "text", text: JSON.stringify(["beta"], null, 2) }])
}),
)
+69 -1
View File
@@ -45,6 +45,11 @@ const completedOutput = (sessionID: Session.ID) =>
`<subagent sessionID="${sessionID}" state="completed">\n${childText}\n</subagent>`
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
const overrideModel = Model.Ref.make({
id: Model.ID.make("override"),
providerID: Provider.ID.make("test"),
variant: Model.VariantID.make("fast"),
})
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const outputSessionID = (value: unknown) =>
@@ -107,7 +112,7 @@ const executionNode = makeGlobalNode({
const subagentPluginSupervisor = makeLocationNode({
name: "test/subagent-plugins",
layer: Layer.effectDiscard(registerToolPlugin(SubagentTool.Plugin)),
deps: [Agent.node, Config.node, Permission.node, Session.node, Job.node, Tool.node],
deps: [Agent.node, Config.node, Model.node, Permission.node, Session.node, Job.node, Tool.node],
})
const nodes = LayerNode.group([
@@ -155,6 +160,13 @@ const withSubagent = (location: Location.Ref) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
yield* Plugin.Service.use((plugins) => plugins.awaitActivation).pipe(Effect.provide(locations.get(location)))
yield* Provider.Service.use((providers) =>
providers.transform((editor) => {
editor.models.update(overrideModel.providerID, overrideModel.id, (model) => {
model.variants = [{ id: Model.VariantID.make("fast") }]
})
}),
).pipe(Effect.provide(locations.get(location)))
yield* Agent.Service.use((agents) =>
agents.transform((editor) => {
// The caller identity used by executeTool; subagent permission asserts against it.
@@ -615,6 +627,62 @@ describe("SubagentTool", () => {
),
)
it.live("runs the child on an explicitly requested model", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((dir) =>
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* Session.Service
const parent = yield* sessions.create({ location, model: parentModel })
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
const call = (id: string, input: Record<string, unknown>) =>
executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call" as const,
id,
name: SubagentTool.name,
input: { agent: "reviewer", description: "review", prompt: "review this", ...input },
},
})
// The requested model beats the agent's configured model.
const spawned = yield* call("call-override", { model: "test/override#fast" })
expect(spawned).toMatchObject({ status: "completed", metadata: { status: "completed" } })
const child = yield* sessions.get(outputSessionID(spawned.metadata))
expect(child).toMatchObject({ agent: "reviewer", model: overrideModel })
// Continuing with a model switches the existing child even when the agent is unchanged.
const continued = yield* call("call-override-continue", { sessionID: child.id, model: "test/override" })
expect(continued).toMatchObject({ status: "completed", metadata: { sessionID: child.id } })
expect((yield* sessions.get(child.id)).model).toEqual({
id: overrideModel.id,
providerID: overrideModel.providerID,
variant: Model.VariantID.make("default"),
})
const failures = [
["not-a-ref", 'Invalid model "not-a-ref". Use "provider/model" or "provider/model#variant".'],
["test/missing", 'Model "test/missing" is not available. Use the models tool to see what is available.'],
["test/override#slow", 'Variant "slow" is not available for "test/override". Available: fast.'],
] as const
for (const [model, message] of failures) {
expect(yield* call(`call-${model}`, { model })).toEqual({
status: "error",
error: { type: "tool.execution", message },
})
}
}),
),
),
)
it.live("returns child runner failures as tool errors", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+1 -10
View File
@@ -4,7 +4,6 @@ import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { Host } from "./host.js"
import { localSource } from "./source.js"
import { missingPackageTarget } from "./source.package.js"
let generation = Date.now()
@@ -38,15 +37,7 @@ export async function prepareSource(entrypoint: string, track: (file: string, di
item.path.startsWith("./") || item.path.startsWith("../")
? new URL(item.path, pathToFileURL(file))
: localSource(item.path, path.dirname(file))
if (!local) {
try {
Bun.resolveSync(item.path, path.dirname(file))
} catch {
const target = missingPackageTarget(item.path, file)
if (target) track(target, true)
}
continue
}
if (!local) continue
const requested = fileURLToPath(local)
// Resolving a workspace symlink can erase its node_modules boundary.
if (requested.split(path.sep).includes("node_modules")) continue
+1 -10
View File
@@ -2,7 +2,6 @@ import { registerHooks } from "node:module"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { localSource } from "./source.js"
import { missingPackageTarget } from "./source.package.js"
import { Host } from "./host.js"
let generation = Date.now()
@@ -22,15 +21,7 @@ export async function prepareSource(entrypoint: string, track: (file: string, di
specifier.startsWith("./") || specifier.startsWith("../")
? new URL(specifier, context.parentURL)
: localSource(specifier, path.dirname(fileURLToPath(context.parentURL)))
if (!local) {
try {
return nextResolve(specifier, context)
} catch (error) {
const target = missingPackageTarget(specifier, fileURLToPath(context.parentURL))
if (target) track(target, true)
throw error
}
}
if (!local) return nextResolve(specifier, context)
if (fileURLToPath(local).split(path.sep).includes("node_modules")) return nextResolve(specifier, context)
const resolved = (() => {
try {
-13
View File
@@ -1,13 +0,0 @@
import { existsSync } from "node:fs"
import path from "node:path"
export function missingPackageTarget(specifier: string, importer: string) {
if (specifier.startsWith("#")) return undefined
const parts = specifier.split("/")
const name = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]
const start = path.dirname(importer)
for (let directory = start; ; directory = path.dirname(directory)) {
if (existsSync(path.join(directory, "package.json"))) return path.join(directory, "node_modules", name)
if (path.dirname(directory) === directory) return path.join(start, "node_modules", name)
}
}
-6
View File
@@ -15250,9 +15250,6 @@
},
"redirect_uri": {
"type": "string"
},
"auth_server_metadata_url": {
"type": "string"
}
},
"additionalProperties": false
@@ -15552,9 +15549,6 @@
},
"requireAssistantAfterTool": {
"type": "boolean"
},
"supportsPromptCacheKey": {
"type": "boolean"
}
},
"additionalProperties": false
-4
View File
@@ -44,10 +44,6 @@ export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
scope: Schema.String.pipe(optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional),
redirect_uri: Schema.String.pipe(optional),
auth_server_metadata_url: Schema.String.pipe(optional).annotate({
description:
"URL of the OAuth or OpenID Connect authorization server metadata document. Set when the MCP server does not publish protected resource metadata that names its authorization server.",
}),
}) {}
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
@@ -42,12 +42,8 @@ function collapseTail(output: string, maxLines: number, maxChars: number) {
const lines = output.split("\n")
if (lines.length <= maxLines && Array.from(output).length <= maxChars) return output
const count = Math.max(1, lines.length - Math.max(0, maxLines - 1))
const label = `(${count} earlier ${count === 1 ? "line" : "lines"})`
if (maxLines <= 1) return label
const preview = Array.from(lines.slice(-(maxLines - 1)).join("\n"))
const available = maxChars - Array.from(label).length - 1
if (available <= 0) return label
return `${label}\n${preview.slice(-available).join("")}`
const preview = lines.slice(-maxLines).join("\n")
const visible = Array.from(preview)
if (visible.length < maxChars) return `${preview}`
return `${visible.slice(-Math.max(0, maxChars - 1)).join("")}`
}
+1 -10
View File
@@ -87,9 +87,8 @@ test("custom commands commit the captured agent, model and variant before execut
expect(mutations).toEqual([{ type: "agent", body: { agent: "plan" } }])
// A later local edit must not change the in-flight command's selection.
await waitForFrame(setup, (frame) => frame.includes("Plan · second model Demo · low"))
setup.mockInput.pressKey("F7")
await waitForFrame(setup, (frame) => frame.includes("high"))
await setup.waitForFrame((frame) => frame.includes("high"))
agent.resolve(new Response(null, { status: 204 }))
await setup.waitFor(() => mutations.length === 2)
expect(mutations[1]).toEqual({
@@ -107,11 +106,3 @@ test("custom commands commit the captured agent, model and variant before execut
model.resolve(new Response(null, { status: 204 }))
}
})
async function waitForFrame(setup: Awaited<ReturnType<typeof createAppFixture>>, matches: (frame: string) => boolean) {
const started = Date.now()
while (!matches(setup.captureCharFrame())) {
if (Date.now() - started > 2_000) throw new Error("Timed out waiting for command selection frame")
await Bun.sleep(10)
}
}
+1 -9
View File
@@ -88,7 +88,7 @@ export async function renderLocal(
),
{ width: 100, height: 30, kittyKeyboard: true },
)
await waitForModel(() => local !== undefined && local.model.ready)
await setup.waitFor(() => local !== undefined && local.model.ready)
await data.location.sync()
return {
...setup,
@@ -106,14 +106,6 @@ export async function renderLocal(
}
}
async function waitForModel(ready: () => boolean) {
const started = Date.now()
while (!ready()) {
if (Date.now() - started > 2_000) throw new Error("Timed out waiting for local model data")
await Bun.sleep(10)
}
}
export function model(id: string, variants: string[] = []): ModelInfo {
return {
id,
-31
View File
@@ -89,30 +89,6 @@ test("renamed exports, failed loads, and new dependencies recover without cached
expect((await sources.read(entry.href)).module).toMatchObject({ default: 3 })
})
test("a missing package dependency reloads when it is installed", async () => {
let changes = 0
const watcher = createSourceWatcher(() => {
changes++
})
using _watcher = { [Symbol.dispose]: watcher.dispose }
await using sources = await fixture(watcher.wait)
const entry = new URL("tui.ts", sources.url)
await Bun.write(entry, 'export { default } from "example"')
await expect(sources.read(entry.href)).rejects.toThrow("example")
const count = changes
await Bun.write(new URL("node_modules/example/package.json", sources.url), '{"type":"module","main":"index.js"}')
await Bun.write(new URL("node_modules/example/index.js", sources.url), 'export default "installed"')
const deadline = Date.now() + 3000
while (Date.now() < deadline) {
if (changes > count) break
await Bun.sleep(10)
}
expect(changes).toBeGreaterThan(count)
expect((await sources.read(entry.href)).module).toMatchObject({ default: "installed" })
})
test("shared runtime and ordinary package identities survive plugin generations", async () => {
await using sources = await fixture()
const entry = new URL("tui.ts", sources.url)
@@ -247,13 +223,6 @@ test.each(["", "?mode=plugin", "?mode=plugin#section"])(
assert.equal(new URL(updated.source).pathname, helper.pathname)
assert.equal(new URL(updated.source).searchParams.get("mode"), suffix ? "plugin" : null)
assert.equal(new URL(updated.source).hash, suffix.includes("#") ? "#section" : "")
const missing = new URL("./missing.mjs", import.meta.url)
await writeFile(missing, 'export { default } from "later"')
await assert.rejects(sources.read(missing.href), /later/)
await mkdir(new URL("./node_modules/later", import.meta.url), { recursive: true })
await writeFile(new URL("./node_modules/later/package.json", import.meta.url), '{"type":"module","main":"index.js"}')
await writeFile(new URL("./node_modules/later/index.js", import.meta.url), 'export default "installed"')
assert.equal((await sources.read(missing.href)).module.default, "installed")
console.log("node graph reload passed")
} finally { sources.dispose() }
`,
-6
View File
@@ -15250,9 +15250,6 @@
},
"redirect_uri": {
"type": "string"
},
"auth_server_metadata_url": {
"type": "string"
}
},
"additionalProperties": false
@@ -15552,9 +15549,6 @@
},
"requireAssistantAfterTool": {
"type": "boolean"
},
"supportsPromptCacheKey": {
"type": "boolean"
}
},
"additionalProperties": false
-6
View File
@@ -15250,9 +15250,6 @@
},
"redirect_uri": {
"type": "string"
},
"auth_server_metadata_url": {
"type": "string"
}
},
"additionalProperties": false
@@ -15552,9 +15549,6 @@
},
"requireAssistantAfterTool": {
"type": "boolean"
},
"supportsPromptCacheKey": {
"type": "boolean"
}
},
"additionalProperties": false
@@ -1,8 +1,8 @@
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
Google Inc.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
@@ -230,7 +230,6 @@ When a provider gives you client credentials, use V2's snake_case OAuth fields:
| `scope` | Space-delimited scopes to request. |
| `callback_port` | Local callback port from `1` through `65535`. An available ephemeral port is the default. |
| `redirect_uri` | Pre-registered loopback URI whose path and port reach the local callback listener. |
| `auth_server_metadata_url` | URL of the authorization server's OAuth or OpenID Connect metadata document. Set it when the MCP server does not publish protected resource metadata that names its authorization server. |
Remove stored OAuth credentials when you need to sign in again or switch accounts:
+23 -8
View File
@@ -1,23 +1,39 @@
@font-face {
font-family: "IBM Plex Mono";
src: url("../assets/fonts/IBMPlexMonoVariable-Roman.woff2") format("woff2");
font-weight: 100 700;
src: url("../assets/fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IBM Plex Mono";
src: url("../assets/fonts/IBMPlexMonoVariable-Italic.woff2") format("woff2");
font-weight: 100 700;
src: url("../assets/fonts/ibm-plex-mono-latin-700-normal.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "IBM Plex Mono";
src: url("../assets/fonts/ibm-plex-mono-latin-400-italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "IBM Plex Mono";
src: url("../assets/fonts/ibm-plex-mono-latin-700-italic.woff2") format("woff2");
font-weight: 700;
font-style: italic;
font-display: swap;
}
:root {
color-scheme: dark;
--background: #000000;
--foreground: #efefef;
--background: #090909;
--foreground: #ededed;
--muted: #929292;
--border: #303030;
--surface: #101010;
@@ -62,8 +78,7 @@ body {
color: var(--foreground);
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.8em;
line-height: 1.5;
letter-spacing: 0.02em;
}