CodeNomad/scripts/test-opencode-proxy-native.mjs
Pascal André aa51cbb9ff
fix(opencode): support earlier and modern V2 runtime contracts (#695)
Closes #694

## Status

This PR contains the stable 2.0.4 client migration, authenticated V2
discovery fallback and shared cross-runtime compatibility
implementation. The validation results below are local execution
evidence; the newly added CI matrix must also run on the updated PR
head.

## Published changes

- Pin UI/server to official `@opencode/client@2.0.4` and the bundled
pruning plugin to `@opencode/plugin@2.0.4`, with the corresponding
lockfile migration.
- Probe authenticated `/api/status` first. Fall back to earlier V2
`/api/health` **only on HTTP 404**, retaining the endpoint, credentials,
64 KiB response bound and absolute deadline. Authentication, transport,
server and malformed-status failures do not trigger fallback.
- Migrate the canonical UI/server contract to stable Forms, permission
decisions, command names, interrupts, renaming, forks, message lookup
and inbox delivery.
- Update the guarded proxy for stable methods/paths, native
active-session envelopes, native `cursor.next`, global Form directory
headers and current resource/plugin events.
- Follow the renamed upstream platform binary `bin/opencode(.exe)` while
retaining public CLI aliases.

Discovery success alone does not establish compatibility with an earlier
runtime's conversation APIs.

## Shared cross-runtime compatibility

### Shared cross-runtime integration

- Introduce one connection-scoped adapter under
`packages/server/src/opencode/compatibility/`, used by both the guarded
UI proxy and direct server callers, including Yolo.
- Select the contract using authenticated daemon identity rather than
the CLI executable's version. Recognize unknown publications through
authenticated, bounded OpenAPI negotiation instead of an exact
runtime-version gate.
- Bind authorization, client, transport and profile to one connection
generation. Fence actual mutation dispatch after asynchronous body
preparation; prevent late old streams from publishing or invalidating
their replacement.
- Translate earlier V2 routes, methods and payloads before sending.
Never retry a mutation speculatively after 400, 404, authentication or
transport failures.
- Normalize legacy HTTP inbox timestamps, including compaction
admission, before the pinned Solid reducer. Preserve the distinct native
SSE inbox schema and durable event metadata.
- Restore legacy catalog/content invalidation and permission-event
convergence. Preserve meaningful method/path/status/profile diagnostics
and native 401 error envelopes.

### Location authority and pruning

- Preserve legacy workspace identity internally through request
serialization, catalog/cache keys, Forms, imports, cursors, events and
exact move rollback. Modern public APIs continue to reject obsolete
workspace selectors.
- Authorize complete locations before translating directories or
synthesizing upstream Form/location headers. Keep native cursor tokens
unchanged.
- Preserve session/path ownership, worktree mutation fences and the
restricted proxy allowlist. Generic RPC, plugin management and
destructive revert commit remain blocked.
- Carry verified legacy identity through pruning membership checks,
preview and the existing transactional storage/claim fence; do not relax
deletion checks.
- Treat native idle records as control records, retaining
identity/outcome without fabricated assistant text.
- Add a common multi-runtime CI matrix and the audit/evidence register
in `dev-docs/OPENCODE_V2_COMPATIBILITY.md`, including preserved
historical architecture findings.

## Local validation

All native tests use isolated synthetic daemons, configuration and
databases, never the shared daemon or user storage.

| Check | Result |
| --- | --- |
| Same native suite on beta-19271, 2.0.3, 2.0.4 and 2.0.5 with pinned
client/plugin 2.0.4 | Passed |
| Full native pruning suite with non-null legacy workspace identity on
beta-19271 and 2.0.3 | Passed |
| Rendered UI on modern 2.0.5 and beta-19271 with non-null identity |
Passed |
| Native 2.0.5 suite inside Ubuntu/WSL, using a temporary Linux
filesystem root | Passed |
| Server tests (after CI follow-up) | 500 passed, 2 skipped |
| UI store tests | 441 passed |
| Electron native tests | 194 passed, 4 skipped |
| UI/server/Electron typechecks | Passed |
| UI/server and Electron production builds | Passed |
| Windows Tauri release executable (`--no-bundle`) | Passed |
| Electron/Tauri packaged-resource smoke checks | Passed |
| Desktop packaging invariants | 4 passed |
| `git diff --check` | Passed |

The native matrix exercises the actual shared connection, generated
client, UI fetch adapter and guarded proxy: voice-off/on instructions,
prompts, commands, session Shell, background Shell output
cursors/removal, UI/Yolo permission acceptance and rejection, busy inbox
delivery/cancellation, interrupt/wait, staged revert clearing,
session/global/worktree Forms, native pagination, export/import,
same-directory legacy identities, scoped events, foreign authority
refusal and exact rollback.

Pruning coverage includes actual storage identity and execution claims,
busy refusal, idempotency, competing admission, next-model payload, fork
isolation, pre-compaction history, restart, backend presence
disposal/reopening and crash expiry. Rendered tests cover
individual/group/response/session cleanup, confirmation counts,
persistence and reload.

## Autonomous gatekeeper review

Review/fix/retest loops concluded with no remaining actionable findings
in the reviewed transport/lifecycle/proxy,
identity/UI/events/pruning/rollback, scripts/CI and rendered-fixture
scopes.

Corrections included dispatch-generation races, compaction admission
normalization, independent negotiation cancellation, first-request
profile resolution, preservation of streamed 401 envelopes and direct
Shell/PTY identity propagation. A final rendered failure was traced to
the fixture awaiting only the first RPC of a cross-message deletion; the
fixture and regression now await every affected message. Production
deletion guards were not relaxed.

This scoped approval does not close the separately preserved historical
architecture findings.

## Validation boundaries

### CI follow-up

The first run on `a0c1c943`
([35142559235](https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/35142559235))
failed in five native jobs. The local bundle tests had missed the
standalone npm archive's missing sibling import, and Windows legacy jobs
exposed a short-path versus canonical-path project comparison. The
dependent artifact-comment job reported that same build failure.

The follow-up makes the plugin's identity primitives self-contained,
adds an isolated npm-file-list regression for all exported entrypoints,
and corrects project identity selection with alias and nested-repository
regressions. The packed-plugin failure was reproduced before correction;
the corrected installed package passes the native suite on Windows 2.0.4
and Linux/WSL 2.0.4/2.0.5. The corrected 2.0.4 shipped bundle also
passes rendered/native pruning. Fresh full beta-19271 and 2.0.3
`--legacy-pruning` suites pass under a reproducing Windows short-path
TEMP root after the final exact-directory fix. Independent final reviews
approve packaging/documentation and worktree identity with zero
actionable findings. Remote checks on the new head remain the CI
authority; these are local follow-up results.

- Full interaction with the installed desktop application through
Developer Mode was not verified because Developer Mode is inactive. The
release executable and packaged-resource checks above are build/smoke
evidence, not a fresh-install interactive certification.
- Remote run
[35146390688](https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/35146390688)
at `486e856d` passes all runtime-contract jobs, Windows/Linux native
pruning, service compatibility, general tests and both Tauri checks.
macOS passes packed-plugin loading/pruning, then times out on late
bundle discovery. The fixture now canonicalizes its root before daemon
startup to align `/var` configuration paths with `/private/var` FSEvents
paths. Late installation remains mandatory and asserts the plugin was
initially absent; failures now upload isolated diagnostic
logs/inventory/captures. Actual macOS confirmation remains pending on
the new head.
- Interactive native TUI validation was not performed.
- The passing WSL result is Linux-native execution inside WSL, not a
complete Windows-host/WSL desktop traversal. With the fixture's
configuration directory under `/mnt/c`, conversation/proxy checks passed
but late plugin discovery timed out; the otherwise equivalent
Linux-filesystem run passed. Mounted-configuration late discovery
remains a documented environment limitation.
- Other audited publications are declaration-reviewed, not individually
native-certified. The four-runtime matrix does not imply universal V2
compatibility.
- Earlier PR artifacts do not contain the completed cross-runtime
implementation; use artifacts built from the updated head. Local passing
results do not imply that the updated remote CI has completed.
2026-09-16 23:48:08 +02:00

94 lines
5.3 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 { 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,
...(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
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)
// 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() }
}
}