CodeNomad/scripts/test-opencode-proxy-native.mjs
Pascal André a9e624baf5
fix(opencode): qualify 2.0.22 and fence parent session creation (#830)
## Summary

Qualify **OpenCode 2.0.22**, align server/UI client and plugin pins, and
recommend this release. Keep the demonstrated **2.0.7** global minimum
and **2.0.20** Codex Usage feature-local requirement.

Block the new native `parentID` create variant in the workspace proxy:
upstream ignores the supplied location and inherits the parent's
directory. Require inspectable JSON objects to close opaque-body
bypasses. Root creation and the existing authorized fork route remain
available.

Started at `dev@0983db3c` (#828 Windows test harness), then integrated
`dev@97c361c3` (#789 Code Mode presentation). Conversation remains
attached to `D:\CodeNomad`; all work and tests use explicit separate
paths and synthetic data.

## Upstream audit: 2.0.21 → 2.0.22

- Authenticated native OpenAPI retains **116 paths**, with **11 schema
differences**: session creation gains optional `parentID`,
parent-not-found response/description; provider settings gain optional
`headerTimeout` and `chunkTimeout=false`; configured model capability
overrides become partial. No new proxy route or consumed mandatory API.
- Published declarations: **4/30 client, 2/60 plugin, 3/36 protocol,
4/102 schema** files change. Plugin session domains expose `remove` and
`compact`; CodeNomad does not add calls to them. Identifier namespace
export is additive.
- An isolated native fixture proves parent creation inherits the parent
location despite a different supplied directory. Mock and
production-proxy fixtures reject owned/foreign parents; malformed values
and opaque content types are covered before any native request. Owned
root creation stays available.
- Runtime changes include HTTP header/chunk/whole-response timeout
handling and bounded timeout retries, provider cache/error/tool-history
fixes, model capability defaults, and legacy MCP shutdown cleanup.
- Lock changes are limited to six `@opencode` packages and two workspace
entries. Integrity/dependency metadata matches independently installed
published packages; clean `npm ci --ignore-scripts` passes. Optional
OpenTUI peers are not installed by CodeNomad.

## Isolated local qualification

**Windows / Node 24.20.0**, isolated profiles, databases, services and
synthetic providers/credentials. No shared-daemon or personal-history
mutations.

- Previous **2.0.21 dependencies + runtime 2.0.22**: native
pruning/history/proxy acceptance passes.
- New **2.0.22 dependencies + runtimes 2.0.7 and 2.0.22**: seven suites
pass (automation, pruning/history/navigation, per-send environment,
inclusive forks, idle/busy side questions, blank-session cleanup,
Git-degraded recovery), plus explicit native proxy suites.
- Native/UI pruning acceptance passes through the committed parent
runner, with completion and actual child exit verified. This uses #828's
early dependency-import workaround, not the old late-import order. No
claim that underlying Windows heap corruption is repaired.
- Current compaction-isolation acceptance passes: session B progresses
while session A's provider response is held; bounded outline reads
retain daemon heartbeats. Fixture runtime selection is extended to
include 2.0.22; its assertions are unchanged. This supplemental fixture
was not qualified on 2.0.7 (explicit runtime selector rejects it).
- Native Codex Usage passes on 2.0.22 with synthetic OAuth/key
credentials and mocked quota HTTP; not a live-account/WSL test.
- Native migrations **2.0.3 → 2.0.22** and **beta-19271 → 2.0.22**
preserve full history/forks/inbox/provider selection.
- Packed standalone pruning plugin installed outside the repository
passes native acceptance.
- Native npm upgrade **2.0.15 → 2.0.22** passes; authenticated daemon
remains **2.0.15 / PID 43876** before and after.
- Full server run: **794 passed / 6 skipped / 0 failed**. Final guard
revision separately passes **68 focused server tests** and native proxy
suites on minimum/current. Aggregate runs use `--test-force-exit`.
- Targeted client/store/reducer/usage UI: **63/63** with
`--conditions=browser`, `--test-force-exit`, bounded timeout. Initial
broad UI run used the wrong Node conditions and hung at
`prioritized-read.test.ts`; only its owned processes were stopped. That
incomplete run is **not green**. The same prioritized-read tests pass
under the browser condition used by CI. No production fix is inferred
from this launch correction.
- **41 browser scenarios** pass: setup/install, auth recovery, tool
images, rendered Codex Usage and compaction responsiveness. Browser
tests do not use forced exit.
- Server/UI/Electron typechecks and production server/UI/plugin builds
pass. Revalidation after #789 integration and independent gatekeeper are
reported separately below.

Local results do not certify all real providers, Linux/macOS/WSL, or
installed Electron/Tauri hosts. Historical Windows import and Git
cleanup failures remain limitations, not repaired by this upgrade.
Remote CI is not continuously monitored and no global CI-green claim is
made.

Size note: existing `packages/server/src/server/http-server.ts` is
~2,359 lines;
`packages/server/src/server/__tests__/instance-proxy.test.ts` is ~1,139
lines. Changes are narrowly scoped; no unrelated size-driven refactor.

## Independent gatekeeper

Review launched on exact head `0a7615476034eabbff54967aacc8a93ab0e6ab12`
against `dev@97c361c33f6fa7de30be5dde31c0e26e12bab574`; verdict will be
published before any admin merge. Findings will be corrected and
reviewed again until zero actionable findings.
2026-10-02 15:47:14 +02:00

113 lines
6.8 KiB
JavaScript

import assert from "node:assert/strict"
import path from "node:path"
import Fastify from "fastify"
import replyFrom from "@fastify/reply-from"
import pino from "pino"
import { OpenCode } from "@opencode/client"
import { tsImport } from "tsx/esm/api"
// Called only by the isolated native fixture; no discovery or user storage.
export async function testNativeProxy({ client, baseUrl, root, authorization, runtimeFetch, connection, exercise }) {
const { registerInstanceProxyRoutes } = await tsImport("../packages/server/src/server/http-server.ts", import.meta.url)
const { PROMPT_INLINE_FILE_LIMITS } = await tsImport("../packages/server/src/api-types.ts", import.meta.url)
const { sessionEnvironment } = await tsImport("../packages/server/src/workspaces/session-environment.ts", import.meta.url)
const { createInstanceFetch } = await tsImport("../packages/ui/src/lib/sdk-manager.ts", import.meta.url)
const app = Fastify()
await app.register(replyFrom)
const owns = candidate => path.resolve(candidate) === path.resolve(root)
registerInstanceProxyRoutes(app, {
workspaceManager: {
get: () => ({ id: "native", path: root }),
getSharedServiceEndpoint: async () => ({ url: baseUrl }),
...(connection ? { getSharedServiceConnection: async () => connection } : {}),
getInstanceAuthorizationHeader: () => authorization,
getServiceDirectory: () => root,
getSharedServiceClient: async () => client,
getSessionEnvironment: () => sessionEnvironment({}),
...(runtimeFetch ? { getSharedServiceFetch: async () => runtimeFetch } : {}),
getWorktreeIdentityForPath: async (_id, directory) => owns(directory) ? root : undefined,
ownsDirectory: async (_id, directory) => owns(directory),
ownsLocation: async (_id, location) => owns(location.directory) && location.workspaceID === undefined,
ownsPath: async (_id, candidate) => owns(candidate),
},
worktreeDeletionFence: { enter: () => () => {} },
logger: pino({ level: "silent" }),
})
await app.listen({ host: "127.0.0.1", port: 0 })
const proxyBase = `http://127.0.0.1:${app.server.address().port}/workspaces/native/instance/`
const forward = createInstanceFetch(proxyBase)
const proxy = OpenCode.make({ baseUrl: proxyBase, fetch: async (input, init) => {
const response = await forward(input, init)
if (!response.ok) assert.fail(`${init?.method} ${input}: ${response.status} ${await response.text()}`)
return response
} })
const created = []
try {
const location = { directory: root }
const info = await proxy.location.get({ location })
assert.ok(info.project.id)
assert.ok(Array.isArray(await proxy.project.list()))
for (const resource of [proxy.agent, proxy.provider, proxy.model, proxy.command, proxy.plugin, proxy.mcp]) {
assert.ok(Array.isArray((await resource.list({ location })).data))
}
assert.ok(Array.isArray((await proxy.permission.request.list({ location })).data))
for (let index = 0; index < 3; index++) created.push(await proxy.session.create({ location }))
const sessionID = created[0].id
for (const parentID of [sessionID, "ses_foreign_parent"]) {
const rejected = await app.inject({ method: "POST", url: "/workspaces/native/instance/api/session",
payload: { parentID, location } })
assert.equal(rejected.statusCode, 403, "Native parent creation must not bypass workspace authority")
}
console.log("PASS: native parent-create variant stays closed through the production proxy; ordinary root creation remains available")
await proxy.session.update({ sessionID, title: "Stable proxy fixture" })
assert.equal((await proxy.session.get({ sessionID })).title, "Stable proxy fixture")
const page = await proxy.session.list({ directory: root, limit: 1 })
assert.equal(page.data.length, 1)
assert.ok(page.cursor.next)
const continuation = await proxy.session.list({ cursor: page.cursor.next, limit: 1 })
assert.equal(continuation.data.length, 1)
assert.notEqual(continuation.data[0].id, page.data[0].id)
assert.equal(typeof await proxy.session.active(), "object")
await proxy.session.instructions.entry.put({ sessionID, key: "fixture", value: "Fixture instruction" })
await proxy.session.instructions.entry.remove({ sessionID, key: "fixture" })
await proxy.session.wait({ sessionID })
const header = { headers: { "x-opencode-directory": encodeURIComponent(root) } }
for (const owner of [sessionID, "global"]) {
for (const action of ["reply", "cancel"]) {
const form = await client.session.form.create({
sessionID: owner, title: "Fixture form", fields: [{ key: "answer", type: "string" }],
}, owner === "global" ? header : undefined)
const listed = await proxy.form.list({ location })
assert.ok(listed.data.some(item => item.id === form.id))
const input = { sessionID: owner, formID: form.id }
if (action === "reply") await proxy.session.form.reply({ ...input, answer: { answer: "ok" } }, header)
else await proxy.session.form.cancel(input, header)
assert.ok(!(await proxy.form.list({ location })).data.some(item => item.id === form.id))
}
}
await exercise?.(proxy, sessionID)
const inlineSessionID = created[1].id
const inlineFile = Buffer.alloc(PROMPT_INLINE_FILE_LIMITS.maxFileBytes, 0x61).toString("base64")
await proxy.session.prompt({
sessionID: inlineSessionID,
text: "Acknowledge the attached transport fixture.",
files: [{ name: "inline-limit.txt", uri: `data:text/plain;base64,${inlineFile}` }],
})
await proxy.session.wait({ sessionID: inlineSessionID }, { signal: AbortSignal.timeout(60_000) })
assert((await proxy.message.list({ sessionID: inlineSessionID, limit: 20 })).data.some(message => message.type === "user"))
console.log(`PASS: ${PROMPT_INLINE_FILE_LIMITS.maxFileBytes}-byte inline file through real proxy and OpenCode endpoint`)
// Export remains a direct native read; it is deliberately not added to the
// guarded UI allowlist merely for this fixture.
const exported = await client.session.export({ sessionID })
await proxy.session.remove({ sessionID })
const restored = await proxy.session.import({ ...exported, location })
assert.equal(restored.id, sessionID)
assert.equal((await proxy.message.list({ sessionID, limit: 100 })).data.length, exported.messages.length)
console.log("PASS: generated stable client through real proxy: catalogs, sessions, native cursors, active envelope, instructions, wait and session/global Forms")
} finally {
// Import can fail after its source has already been removed. Cleanup must
// neither replace that failure with a 404 nor leave the HTTP listener open.
try { await Promise.allSettled(created.map(session => client.session.remove({ sessionID: session.id }))) }
finally { await app.close() }
}
}