From 5a138b9d9f9129d9da5c0fc0b72ecb770c396392 Mon Sep 17 00:00:00 2001 From: aiyusuf-1 Date: Sun, 30 Aug 2026 12:12:52 +0000 Subject: [PATCH 1/4] Add devcontainer for project setup 1a --- .devcontainer/Dockerfile | 12 ++++++++++++ .devcontainer/devcontainer-lock.json | 9 +++++++++ .devcontainer/devcontainer.json | 10 ++++++++++ 3 files changed, 31 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..f2bb45f8 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22 + +# Install basic development tools +RUN apt update && apt install -y less man-db sudo + +# Ensure default `node` user has access to `sudo` +ARG USERNAME=node +RUN echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \ + && chmod 0440 /etc/sudoers.d/$USERNAME + +# Set `DEVCONTAINER` environment variable to help with orientation +ENV DEVCONTAINER=true diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000..a093533f --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/shyim/devcontainers-features/bun:0": { + "version": "0.0.1", + "resolved": "ghcr.io/shyim/devcontainers-features/bun@sha256:689eae681aa08981175829a59953ba67a7d311f6a05c15d1bbbcb2da2839827e", + "integrity": "sha256:689eae681aa08981175829a59953ba67a7d311f6a05c15d1bbbcb2da2839827e" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..bb94417b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,10 @@ +{ + "name": "OpenCode Development", + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "node", + "features": { + "ghcr.io/shyim/devcontainers-features/bun:0": {} + } +} \ No newline at end of file From b4992e9041140a203fc209cfce3a81418c30fd46 Mon Sep 17 00:00:00 2001 From: aiyusuf-1 Date: Sun, 6 Sep 2026 11:47:55 +0300 Subject: [PATCH 2/4] refactor(codemode): decompose copyBounded into single-purpose helpers copyBounded mixed six concerns in one 120-line body: the depth guard, leaf passthrough, un-awaited promise rejection, intra-sandbox host wrapping, boundary serialization and container recursion. Every value type was matched by a hand-written instanceof chain, so adding one meant editing three different chains and the function grew a return statement each time. Replace the chains with ordered lookup tables (HOST_VALUE_WRAPPERS, BOUNDARY_SERIALIZERS) dispatched by a shared matchType helper, and extract isDataLeaf, isSandboxValue, hasNoJsonForm, serializeForBoundary and copyContainer. Ordering within each table preserves the original chain order, which is what makes this behavior-preserving; the sandbox value classes are standalone rather than subclasses of their host counterparts, so no value matches two entries. Qlty (packages/codemode/src/tool-runtime.ts): before: complexity 68, 15 returns, 3 complex binary expressions (185/216/257) after: complexity 19, returns and binary-expression smells cleared file total complexity 266 -> 243 Co-Authored-By: Claude Opus 5 --- packages/codemode/src/tool-runtime.ts | 225 ++++++++++++++++---------- 1 file changed, 137 insertions(+), 88 deletions(-) diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index f4ccc61d..716da9fc 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -171,101 +171,105 @@ export const isBlockedMember = (name: string): boolean => blockedMemberNames.has export const copyIn = (value: unknown, label: string, preserveSandboxValues = false): unknown => copyBounded(value, label, 0, new Set(), preserveSandboxValues) -const copyBounded = ( - value: unknown, - label: string, - depth: number, - seen: Set, - preserveSandboxValues: boolean, -): unknown => { - if (depth > MAX_VALUE_DEPTH) { - throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) - } - if ( - value === null || - value === undefined || - typeof value === "string" || - typeof value === "boolean" || - // NaN/Infinity are allowed to exist as in-sandbox intermediates (matching real JS and a real - // engine) so defensive guards like `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are - // normalized to `null` when the value leaves the sandbox - see copyOut - exactly as - // JSON.stringify already does at any tool boundary. - typeof value === "number" - ) { - return value - } +/** Sentinel meaning "this stage did not recognize the value"; distinct from any real copy result. */ +const NOT_HANDLED = Symbol("not-handled") - if (typeof value !== "object") { - throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) - } +const LEAF_TYPES = new Set(["string", "boolean", "number", "undefined"]) - // An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the - // model exactly how to fix the program instead. - if (value instanceof SandboxPromise) { - throw new ToolRuntimeError( - "InvalidDataValue", - `${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`, - ) - } +/** + * Data-only leaves that cross either mode untouched. NaN/Infinity are allowed to exist as + * in-sandbox intermediates (matching real JS and a real engine) so defensive guards like + * `Number.isNaN(x)` / `parseInt(x) || 0` can run. They are normalized to `null` when the value + * leaves the sandbox - see copyOut - exactly as JSON.stringify already does at any tool boundary. + */ +const isDataLeaf = (value: unknown): boolean => value === null || LEAF_TYPES.has(typeof value) - if (preserveSandboxValues) { - // Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents - // are never walked here (Map/Set members are validated where mutation happens, and the - // real boundary still serializes them below). - if ( - value instanceof SandboxDate || - value instanceof SandboxRegExp || - value instanceof SandboxMap || - value instanceof SandboxSet || - value instanceof SandboxURL || - value instanceof SandboxURLSearchParams - ) { - return value - } - // Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross - // the boundary first), but wrap them defensively rather than degrading to JSON forms. - if (value instanceof Date) return new SandboxDate(value.getTime()) - if (value instanceof RegExp) return new SandboxRegExp(value.source, value.flags) - if (value instanceof Map) { +const SANDBOX_VALUE_TYPES = [ + SandboxDate, + SandboxRegExp, + SandboxMap, + SandboxSet, + SandboxURL, + SandboxURLSearchParams, +] as const + +const isSandboxValue = (value: object): boolean => SANDBOX_VALUE_TYPES.some((type) => value instanceof type) + +/** Value types with no JSON form beyond `{}` - RegExp/Map/Set/URLSearchParams and their sandbox twins. */ +const OPAQUE_JSON_TYPES = [ + SandboxRegExp, + SandboxMap, + SandboxSet, + SandboxURLSearchParams, + RegExp, + Map, + Set, + URLSearchParams, +] as const + +const hasNoJsonForm = (value: object): boolean => OPAQUE_JSON_TYPES.some((type) => value instanceof type) + +type CopyChild = (item: unknown) => unknown + +type Matcher = readonly [ + abstract new (...args: never) => T, + (value: T, copyChild: CopyChild) => unknown, +] + +const matchType = (value: object, matchers: ReadonlyArray>, copyChild: CopyChild): unknown => { + const matched = matchers.find(([type]) => value instanceof type) + return matched ? (matched[1] as (value: object, copyChild: CopyChild) => unknown)(value, copyChild) : NOT_HANDLED +} + +/** + * Host instances cannot normally reach an intra-sandbox checkpoint (tool results cross the + * boundary first), but wrap them defensively rather than degrading to JSON forms. + */ +const HOST_VALUE_WRAPPERS = [ + [Date, (value: Date) => new SandboxDate(value.getTime())], + [RegExp, (value: RegExp) => new SandboxRegExp(value.source, value.flags)], + [ + Map, + (value: Map, copyChild: CopyChild) => { const wrapped = new SandboxMap() - for (const [key, item] of value.entries()) { - wrapped.map.set(copyBounded(key, label, depth + 1, seen, true), copyBounded(item, label, depth + 1, seen, true)) - } + for (const [key, item] of value.entries()) wrapped.map.set(copyChild(key), copyChild(item)) return wrapped - } - if (value instanceof Set) { + }, + ], + [ + Set, + (value: Set, copyChild: CopyChild) => { const wrapped = new SandboxSet() - for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true)) + for (const item of value.values()) wrapped.set.add(copyChild(item)) return wrapped - } - if (value instanceof URL) return new SandboxURL(new URL(value.href)) - if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value)) - } + }, + ], + [URL, (value: URL) => new SandboxURL(new URL(value.href))], + [URLSearchParams, (value: URLSearchParams) => new SandboxURLSearchParams(new URLSearchParams(value))], +] as unknown as ReadonlyArray> - // Sandbox value types (and their host counterparts, which a host tool may legitimately - // return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use - // toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}. - if (value instanceof SandboxDate) { - return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null - } - if (value instanceof Date) { - return Number.isFinite(value.getTime()) ? value.toISOString() : null - } - if (value instanceof SandboxURL) return value.url.href - if (value instanceof URL) return value.href - if ( - value instanceof SandboxRegExp || - value instanceof SandboxMap || - value instanceof SandboxSet || - value instanceof SandboxURLSearchParams || - value instanceof RegExp || - value instanceof Map || - value instanceof Set || - value instanceof URLSearchParams - ) { - return Object.create(null) as SafeObject - } +const isoOrNull = (time: number): string | null => (Number.isFinite(time) ? new Date(time).toISOString() : null) +/** + * Sandbox value types (and their host counterparts, which a host tool may legitimately return) + * serialize exactly as JSON.stringify would at the data boundary: Date/URL use toJSON(), while + * RegExp/Map/Set/URLSearchParams have no JSON form beyond {}. + */ +const BOUNDARY_SERIALIZERS = [ + [SandboxDate, (value: SandboxDate) => isoOrNull(value.time)], + [Date, (value: Date) => isoOrNull(value.getTime())], + [SandboxURL, (value: SandboxURL) => value.url.href], + [URL, (value: URL) => value.href], +] as unknown as ReadonlyArray> + +const serializeForBoundary = (value: object, copyChild: CopyChild): unknown => { + const serialized = matchType(value, BOUNDARY_SERIALIZERS, copyChild) + if (serialized !== NOT_HANDLED) return serialized + return hasNoJsonForm(value) ? (Object.create(null) as SafeObject) : NOT_HANDLED +} + +/** Walks arrays and plain objects, enforcing circularity, prototype and blocked-property rules. */ +const copyContainer = (value: object, label: string, seen: Set, copyChild: CopyChild): unknown => { if (seen.has(value)) { throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`) } @@ -273,7 +277,7 @@ const copyBounded = ( seen.add(value) if (Array.isArray(value)) { - const copied = value.map((item) => copyBounded(item, label, depth + 1, seen, preserveSandboxValues)) + const copied = value.map((item) => copyChild(item)) seen.delete(value) return copied } @@ -288,12 +292,57 @@ const copyBounded = ( if (isBlockedMember(key)) { throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`) } - copied[key] = copyBounded(item, label, depth + 1, seen, preserveSandboxValues) + copied[key] = copyChild(item) } seen.delete(value) return copied } +const copyBounded = ( + value: unknown, + label: string, + depth: number, + seen: Set, + preserveSandboxValues: boolean, +): unknown => { + if (depth > MAX_VALUE_DEPTH) { + throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`) + } + + if (isDataLeaf(value)) return value + + if (value === null || typeof value !== "object") { + throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`) + } + + // An un-awaited promise never crosses a data checkpoint as `{}`; the diagnostic tells the + // model exactly how to fix the program instead. + if (value instanceof SandboxPromise) { + throw new ToolRuntimeError( + "InvalidDataValue", + `${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`, + ) + } + + const copyChild = (item: unknown, preserve = preserveSandboxValues) => + copyBounded(item, label, depth + 1, seen, preserve) + + if (preserveSandboxValues) { + // Intra-sandbox checkpoints keep sandbox value instances alive as leaves; their contents + // are never walked here (Map/Set members are validated where mutation happens, and the + // real boundary still serializes them below). + if (isSandboxValue(value)) return value + + const wrapped = matchType(value, HOST_VALUE_WRAPPERS, (item) => copyChild(item, true)) + if (wrapped !== NOT_HANDLED) return wrapped + } + + const serialized = serializeForBoundary(value, (item) => copyChild(item)) + if (serialized !== NOT_HANDLED) return serialized + + return copyContainer(value, label, seen, (item) => copyChild(item)) +} + export const copyOut = (value: unknown, undefinedAsNull = false): unknown => { if (value === undefined && undefinedAsNull) return null // Normalize non-finite numbers to null as the value crosses out of the sandbox (final return From cb464de9f709819760fb6eecf16691f24a0df9dd Mon Sep 17 00:00:00 2001 From: aiyusuf-1 Date: Sun, 6 Sep 2026 11:47:55 +0300 Subject: [PATCH 3/4] test(codemode): cover copyIn value-type dispatch directly The suite reached copyIn only indirectly through CodeMode.execute, which never drives a host Map/Set/RegExp/URL through an intra-sandbox checkpoint, leaving the wrapper branches uncovered. Add 18 characterization tests over both copyIn modes: leaf passthrough, boundary serialization of Date/URL to strings and of the JSON-formless types to {}, intra-sandbox identity passthrough and host wrapping, and every contract violation (circular, depth limit and just inside it, blocked properties, non-plain objects, non-data leaves). These were written against the pre-refactor implementation and pass unchanged on both it and the refactored one, so they pin behavior rather than describing the new structure. packages/codemode/src/tool-runtime.ts line coverage 99.63% -> 99.64%; the two remaining uncovered lines predate this change. Co-Authored-By: Claude Opus 5 --- packages/codemode/test/tool-runtime.test.ts | 183 ++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 packages/codemode/test/tool-runtime.test.ts diff --git a/packages/codemode/test/tool-runtime.test.ts b/packages/codemode/test/tool-runtime.test.ts new file mode 100644 index 00000000..c813034b --- /dev/null +++ b/packages/codemode/test/tool-runtime.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test" +import { ToolRuntime } from "../src/tool-runtime.js" +import { + SandboxDate, + SandboxMap, + SandboxRegExp, + SandboxSet, + SandboxURL, + SandboxURLSearchParams, +} from "../src/values.js" + +// copyIn walks a value against the plain-data contract in two modes. These tests pin both modes +// directly (the rest of the suite reaches copyIn only indirectly, through CodeMode.execute), so +// the value-type dispatch tables stay covered: +// +// - Boundary (preserveSandboxValues false): sandbox/host value types serialize exactly as +// JSON.stringify would - Date/URL become strings, everything else becomes {}. +// - Intra-sandbox checkpoint (preserveSandboxValues true): sandbox instances pass through as +// leaves, and host instances are defensively wrapped into their sandbox counterparts. +const copyIn = (value: unknown, preserve = false) => ToolRuntime.copyIn(value, "val", preserve) + +describe("copyIn - data leaves", () => { + test("passes primitives through unchanged in both modes", () => { + for (const preserve of [false, true]) { + expect(copyIn({ s: "a", n: 1, b: true, z: null, u: undefined }, preserve)).toEqual({ + s: "a", + n: 1, + b: true, + z: null, + u: undefined, + }) + } + }) + + // NaN/Infinity survive as in-sandbox intermediates so defensive guards can run; copyOut, not + // copyIn, is what normalizes them to null on the way out. + test("keeps non-finite numbers as-is", () => { + const copied = copyIn({ a: NaN, b: Infinity, c: -Infinity }) as Record + expect(Number.isNaN(copied.a)).toBe(true) + expect(copied.b).toBe(Infinity) + expect(copied.c).toBe(-Infinity) + }) + + test("copies nested arrays and plain objects onto null-prototype objects", () => { + const copied = copyIn({ a: [1, { b: [2, 3] }] }) + expect(copied).toEqual({ a: [1, { b: [2, 3] }] }) + expect(Object.getPrototypeOf(copied)).toBeNull() + }) +}) + +describe("copyIn - boundary serialization", () => { + test("serializes Date and URL types to strings", () => { + expect(copyIn(new Date(1700000000000))).toBe("2023-11-14T22:13:20.000Z") + expect(copyIn(new SandboxDate(1700000000000))).toBe("2023-11-14T22:13:20.000Z") + expect(copyIn(new URL("https://ex.com/p?q=1"))).toBe("https://ex.com/p?q=1") + expect(copyIn(new SandboxURL(new URL("https://ex.com/")))).toBe("https://ex.com/") + }) + + test("serializes non-finite Dates to null", () => { + expect(copyIn(new Date(NaN))).toBeNull() + expect(copyIn(new SandboxDate(NaN))).toBeNull() + }) + + test("serializes types with no JSON form to an empty object", () => { + const values = [ + /ab+c/gi, + new Map([["k", 1]]), + new Set([1]), + new URLSearchParams("a=1"), + new SandboxRegExp("x+", "g"), + new SandboxMap(), + new SandboxSet(), + new SandboxURLSearchParams(new URLSearchParams("a=1")), + ] + for (const value of values) expect(copyIn(value)).toEqual({}) + }) +}) + +describe("copyIn - intra-sandbox checkpoint", () => { + test("passes sandbox value instances through by identity", () => { + const values = [ + new SandboxDate(1700000000000), + new SandboxRegExp("x+", "g"), + new SandboxMap(), + new SandboxSet(), + new SandboxURL(new URL("https://ex.com/")), + new SandboxURLSearchParams(new URLSearchParams("a=1")), + ] + for (const value of values) expect(copyIn(value, true)).toBe(value) + }) + + test("wraps a host Date and RegExp into sandbox counterparts", () => { + const date = copyIn(new Date(1700000000000), true) as SandboxDate + expect(date).toBeInstanceOf(SandboxDate) + expect(date.time).toBe(1700000000000) + + const regexp = copyIn(/ab+c/gi, true) as SandboxRegExp + expect(regexp).toBeInstanceOf(SandboxRegExp) + expect(regexp.regex.source).toBe("ab+c") + expect(regexp.regex.flags).toBe("gi") + }) + + test("wraps a host Map and copies its keys and values", () => { + const wrapped = copyIn( + new Map([ + ["k", 1], + ["j", { n: 2 }], + ]), + true, + ) as SandboxMap + expect(wrapped).toBeInstanceOf(SandboxMap) + expect(wrapped.map.get("k")).toBe(1) + expect(wrapped.map.get("j")).toEqual({ n: 2 }) + }) + + test("wraps a host Set and copies its members", () => { + const wrapped = copyIn(new Set([1, "two", { n: 3 }]), true) as SandboxSet + expect(wrapped).toBeInstanceOf(SandboxSet) + expect([...wrapped.set]).toEqual([1, "two", { n: 3 }]) + }) + + test("wraps host URL and URLSearchParams", () => { + const url = copyIn(new URL("https://ex.com/p?q=1"), true) as SandboxURL + expect(url).toBeInstanceOf(SandboxURL) + expect(url.url.href).toBe("https://ex.com/p?q=1") + + const params = copyIn(new URLSearchParams("a=1&b=2"), true) as SandboxURLSearchParams + expect(params).toBeInstanceOf(SandboxURLSearchParams) + expect(params.params.get("b")).toBe("2") + }) + + test("wraps host containers nested inside plain data", () => { + const copied = copyIn({ m: new Map([["k", new Map([["n", 1]])]]) }, true) as { m: SandboxMap } + const outer = copied.m + expect(outer).toBeInstanceOf(SandboxMap) + const inner = outer.map.get("k") as SandboxMap + expect(inner).toBeInstanceOf(SandboxMap) + expect(inner.map.get("n")).toBe(1) + }) +}) + +describe("copyIn - contract violations", () => { + const rejects = (value: unknown, message: string, preserve = false) => + expect(() => copyIn(value, preserve)).toThrow(message) + + test("rejects circular values in both modes", () => { + for (const preserve of [false, true]) { + const circular: Record = { x: 1 } + circular.self = circular + rejects(circular, "val contains a circular value.", preserve) + } + }) + + test("rejects values deeper than the depth limit", () => { + let deep: unknown = 1 + for (let i = 0; i < 40; i++) deep = { a: deep } + rejects(deep, "val exceeds the maximum value depth of 32.") + }) + + test("accepts values just inside the depth limit", () => { + let deep: unknown = 1 + for (let i = 0; i < 31; i++) deep = { a: deep } + expect(() => copyIn(deep)).not.toThrow() + }) + + test("rejects blocked property names", () => { + rejects(JSON.parse('{"__proto__":{"x":1}}'), "val contains blocked property '__proto__'.") + rejects({ constructor: 1 }, "val contains blocked property 'constructor'.") + }) + + test("rejects non-plain objects", () => { + class Custom { + a = 1 + } + rejects(new Custom(), "val must contain plain objects only.") + }) + + test("rejects non-data leaves", () => { + rejects({ f: () => 1 }, "val must contain data only.") + rejects({ s: Symbol("x") }, "val must contain data only.") + rejects({ b: BigInt(5) }, "val must contain data only.") + }) +}) From df1032a2d7359a4999f21ca9b1965f11015a3ee3 Mon Sep 17 00:00:00 2001 From: aiyusuf-1 Date: Sun, 6 Sep 2026 11:47:55 +0300 Subject: [PATCH 4/4] ci(turbo): run codemode tests in the unit job turbo.json only registers test tasks for the packages a change touches, and codemode had no entry, so packages/codemode/test never ran on push despite having its own test script. Add @opencode-ai/codemode#test so the tests covering the copyBounded refactor actually run in CI. The suite is 281 tests in ~1.4s, so this is a negligible addition to the unit job. Co-Authored-By: Claude Opus 5 --- turbo.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/turbo.json b/turbo.json index f59711fa..f1baf9d1 100644 --- a/turbo.json +++ b/turbo.json @@ -42,6 +42,10 @@ "@opencode-ai/session-ui#test": { "dependsOn": ["^build"], "outputs": [] + }, + "@opencode-ai/codemode#test": { + "dependsOn": ["^build"], + "outputs": [] } } }