mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-11-01 19:40:35 +00:00
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.
94 lines
5.3 KiB
JavaScript
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() }
|
|
}
|
|
}
|