mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-15 21:36:21 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83457c8175 |
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,15 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
|
||||
const QUOTA_CODES = new Set([
|
||||
"insufficient_quota",
|
||||
"usage_not_included",
|
||||
"billing_error",
|
||||
"gousagelimiterror",
|
||||
"freeusagelimiterror",
|
||||
"creditlimitexceeded",
|
||||
])
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
@@ -87,7 +95,8 @@ const CONTENT_POLICY_CODES = new Set([
|
||||
// as a `[code]` label at the start of the rewritten message.
|
||||
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
// Only consulted on 429, where throttles and account caps share a status.
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
|
||||
// Policy rejections without a dedicated code, matched against the provider's own
|
||||
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
|
||||
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
|
||||
@@ -143,7 +152,11 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
|
||||
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
|
||||
return new ContentPolicyError(details)
|
||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||
if (
|
||||
input.status === 402 ||
|
||||
codes.some((code) => QUOTA_CODES.has(code)) ||
|
||||
(input.status === 429 && QUOTA_TEXT.test(text))
|
||||
)
|
||||
return new QuotaExceededError(details)
|
||||
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
|
||||
return new AuthenticationError(details)
|
||||
@@ -163,10 +176,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
input.status === 408 ||
|
||||
input.status === 409 ||
|
||||
(input.status !== undefined && input.status >= 500) ||
|
||||
// Server codes and phrasing only decide when no HTTP status contradicts them:
|
||||
// gateways such as OpenCode Zen substitute `server_error` for codes they do
|
||||
// not forward, so a 4xx with a server code is still a rejected request.
|
||||
((input.status === undefined || input.status < 400) &&
|
||||
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
|
||||
SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
|
||||
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
|
||||
)
|
||||
return new ProviderInternalError({
|
||||
...details,
|
||||
|
||||
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
|
||||
it.effect("does not let server codes override a 4xx rejection", () =>
|
||||
Effect.gen(function* () {
|
||||
const classify = (body: string) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
|
||||
|
||||
yield* classify('{"code":"resource_exhausted"}')
|
||||
yield* classify('{"code":"service_unavailable"}')
|
||||
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -249,10 +249,54 @@ describe("provider error classification", () => {
|
||||
|
||||
test("classifies any remaining 4xx status as an invalid request", () => {
|
||||
expect(
|
||||
[400, 402, 404, 418, 422, 451].map(
|
||||
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
|
||||
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
||||
).toEqual(Array(5).fill("InvalidRequest"))
|
||||
})
|
||||
|
||||
test("classifies 402 as exhausted quota", () => {
|
||||
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
|
||||
})
|
||||
|
||||
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
|
||||
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
|
||||
const substituted = (message: string) => ({
|
||||
error: { type: "server_error", message: `Upstream request failed: ${message}` },
|
||||
})
|
||||
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
|
||||
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
|
||||
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
|
||||
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
|
||||
[402, substituted("Insufficient account funds")],
|
||||
[402, substituted("Account invoice is overdue")],
|
||||
[429, substituted("Account budget exceeded")],
|
||||
]
|
||||
expect(
|
||||
cases.map(
|
||||
([status, body]) =>
|
||||
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(Array(6).fill("InvalidRequest"))
|
||||
).toEqual(Array(6).fill("QuotaExceeded"))
|
||||
})
|
||||
|
||||
test("does not let substituted server codes make a 4xx retryable", () => {
|
||||
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
|
||||
const anthropic = {
|
||||
type: "error",
|
||||
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
|
||||
}
|
||||
expect(
|
||||
[openai, anthropic].map(
|
||||
(body) =>
|
||||
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
|
||||
),
|
||||
).toEqual(["InvalidRequest", "InvalidRequest"])
|
||||
// Without a contradicting status the same codes still mark provider trouble.
|
||||
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
|
||||
"ProviderInternal",
|
||||
)
|
||||
expect(
|
||||
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
|
||||
).toBe("ProviderInternal")
|
||||
})
|
||||
|
||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
import { Effect, Ref, Schema, Semaphore } from "effect"
|
||||
import { definition, normalizedName } from "../tool/runtime.js"
|
||||
import { CodeModeCatalog } from "./catalog.js"
|
||||
import { CodeModeWeb } from "./web.js"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
@@ -220,7 +219,7 @@ function runtime(
|
||||
})
|
||||
}
|
||||
const tools = renderTools(root)
|
||||
return CodeMode.make<typeof tools>({ tools, extensions: [CodeModeWeb.extension], ...hooks })
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
|
||||
function getNode<T>(root: Node<T>, path: string) {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
export * as CodeModeWeb from "./web.js"
|
||||
|
||||
import { Extension } from "@opencode/codemode"
|
||||
|
||||
const MAX_BODY_BYTES = 1024 * 1024
|
||||
const TIMEOUT_MS = 30_000
|
||||
|
||||
const fetch = async (input: unknown, init: unknown = {}) => {
|
||||
if (typeof input !== "string" && !(input instanceof URL)) {
|
||||
throw new TypeError("fetch: the first argument must be a URL string or URL.")
|
||||
}
|
||||
if (init === null || typeof init !== "object") throw new TypeError("fetch: init must be an object.")
|
||||
const given = init as { method?: unknown; headers?: unknown; body?: unknown }
|
||||
const body = given.body instanceof Uint8Array ? Uint8Array.from(given.body) : given.body
|
||||
if (
|
||||
body !== undefined &&
|
||||
typeof body !== "string" &&
|
||||
!(body instanceof Uint8Array) &&
|
||||
!(body instanceof URLSearchParams)
|
||||
) {
|
||||
throw new TypeError("fetch: init.body must be a string, Uint8Array, or URLSearchParams.")
|
||||
}
|
||||
const response = await globalThis.fetch(input, {
|
||||
method: given.method === undefined ? undefined : String(given.method),
|
||||
headers: given.headers as Record<string, string> | Array<[string, string]> | undefined,
|
||||
body,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
})
|
||||
if (Number(response.headers.get("content-length")) > MAX_BODY_BYTES) {
|
||||
throw new RangeError(`fetch: response exceeds ${MAX_BODY_BYTES} bytes.`)
|
||||
}
|
||||
const bytes = Uint8Array.from(await response.bytes())
|
||||
if (bytes.byteLength > MAX_BODY_BYTES) throw new RangeError(`fetch: response exceeds ${MAX_BODY_BYTES} bytes.`)
|
||||
const headers = Object.fromEntries(response.headers)
|
||||
const text = () => new TextDecoder().decode(bytes)
|
||||
return {
|
||||
url: response.url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
ok: response.ok,
|
||||
redirected: response.redirected,
|
||||
headers: {
|
||||
get: (name: string) => headers[String(name).toLowerCase()] ?? null,
|
||||
has: (name: string) => String(name).toLowerCase() in headers,
|
||||
entries: () => Object.entries(headers),
|
||||
},
|
||||
text: async () => text(),
|
||||
json: async () => JSON.parse(text()) as unknown,
|
||||
bytes: async () => bytes,
|
||||
}
|
||||
}
|
||||
|
||||
export const extension = Extension.make({ name: "web", globals: { fetch } })
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { afterAll, expect, test } from "bun:test"
|
||||
import { CodeMode } from "@opencode/codemode"
|
||||
import { Effect } from "effect"
|
||||
import { CodeModeWeb } from "../../src/codemode/web.js"
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) =>
|
||||
Response.json(
|
||||
{ method: request.method, path: new URL(request.url).pathname, body: await request.text() },
|
||||
{ headers: { "X-Custom": "yes" } },
|
||||
),
|
||||
})
|
||||
afterAll(() => server.stop(true))
|
||||
|
||||
const runtime = CodeMode.make({ extensions: [CodeModeWeb.extension] })
|
||||
|
||||
test("scripts can fetch and read the response", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
const res = await fetch("${server.url.origin}/hello", { method: "POST", body: "hi" })
|
||||
return [res.ok, res.status, res.headers.get("x-custom"), await res.json()]
|
||||
`),
|
||||
)
|
||||
if (!result.ok) throw new Error(result.error.message)
|
||||
expect(result.value).toEqual([true, 200, "yes", { method: "POST", path: "/hello", body: "hi" }])
|
||||
expect(result.toolCalls).toEqual([])
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("")}`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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:
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user