mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-24 15:21:21 +00:00
## Summary This PR removes OpenCode compatibility code that nothing can reach any more: - Code that was already dead at the former 2.0.27 minimum, and is still dead at the current 2.1.0 minimum (#1029). - Handlers for events and data shapes that OpenCode V2 never produces. Each item was checked against the upstream 2.0.27 and 2.1.0 sources before removal. Kept on purpose: - Workarounds for upstream bugs or limits that are still present in 2.1.0. - Code that reads conversations written by older runtimes: V1-migrated tool names and metadata, `workspaceID` data, task summary titles, `session-legacy` replay. - Code that recognises and upgrades old CLIs and live older daemons: 1.x replacement, beta recognition, 2.0.11 service discovery, 2.0.15 native upgrade, `/api/status` and `/api/health` identification. ## Removed or changed **Server** - Negotiation: one "not modern → `canonical_api`" rule replaces the pre-2.0.4 legacy schema recognition, and runs before #1029's `native_contract` check. - The transport no longer rewrites `/api/status`. - Desktop skills no longer send the pre-2.0.4 `location` and `slash` fields. - The legacy `auth.json` usage fallback for daemons without `credential.list` (pre-2.0.20) is removed, along with its UI reason and the i18n key in 10 locales. A missing endpoint still fails safely. - Workspace selectors are rejected up front, without a native resolution request. - CLI service probes try `/api/info` first. An admitted daemon no longer costs two authenticated 404s on every check. **UI** - Remaining V1 `session.idle`/`session.status` branches: Missions store, pending discovery (UI and server), `eventAffectsMessages`. - `server.instance.disposed` and the now-unused `rehydrateInstance`. - The V1 `worktree.ready` envelope (the handler now reads the V2 shape). - V1 tool-state titles and the `success` status alias. - Part `time.start` and `time.end`. - Text stored as objects or segment arrays. - `deriveMessageStatus`. - The V1 `error.data.message` shape. - `projectID` and `location` fallbacks on `session.created`. - The replay-only `session.message.content.updated`. - The unreachable `output.answers` fallback. **Fixtures, Missions and docs** - Legacy branches removed from the location, fork, families, pruning and automation native fixtures. The WSL setup fixture now compares against the reported version instead of 2.0.11. - Unused 2.0.21 launch helpers removed. - The Missions native and trajectory fixtures also run on the minimum lane. - The capability proof checks the reported version. - Stale 2.0.2x labels now say "as of the 2.1.0 minimum". **Follow-ups from the #1029 post-merge review** - Test fixtures no longer send the ignored `recent: ""` compaction field (18 sites). - The manual compaction-isolation fixture accepts any published version at or above the minimum. - `CONTRIBUTING.md` wording updated. 114 files, +319 / −1117: source +90 / −487, tests/fixtures/CI +214 / −604, i18n −10, docs +15 / −16. ## Validation (rebased on dev with #1029) **Typechecks:** server and UI pass. **Server tests** - Touched areas: 480/480. - Full server CI step: 2430 pass, 0 fail, 7 skipped, 1 cancelled. The cancelled test is the `index.test.ts` real-process shutdown test; it hit its 5 s timeout under load. It passes alone (4/4), and that file is unchanged versus dev. **UI unit tests** - Touched files: 230/230. - CI steps: 574/574, 200/200 and 519/519. **Browser suites**, one at a time: - Provider and service usage: provider-usage 13/13, service-usage 3/3. - Missions: mission-visibility 3/3, mission-control-feedback 5/5, mission-lifecycle-revision 10/10, mission-lifecycle-navigation 10/10, mission-current-passage 1/1, mission-native-family 11/11. - Transcript and rendering: compaction-responsiveness 5/5, session-rendering 47/47, reasoning-collapse 3/3, render-cost 17/17, tool-images 4/4, system-messages 10/10. - Other: search-highlight 11 pass (1 skipped by its env gate), panel-extensions 13/13. **Native fixtures on an isolated 2.1.0 CLI:** location, session-fork, automation, session-pruning, missions native, missions trajectory, provider-usage, Missions capability proof and compaction-isolation all pass. The WSL setup fixture was not run (it needs a WSL distro with a Linux CLI). ## Left for later - `RuntimeIdentity.discovery` is now write-only. - `ownsLocation` has an unused `_client` parameter (about 30 callers). - A test-only V1 translation in `auto-accept-manager.test.ts`. - Out of scope: HTML-entity decoding (a product decision), the `MutableHashMap` loop, the pruning-UI import-order workaround, unused log-based Missions readers, and the unshipped managed-owner module with its 2.0.24-pinned test. Oversized source files touched: `stores/instances.ts` ~2,209, `message-block.tsx` ~1,601, `workspaces/manager.ts` ~1,104, `tool-call.css` ~1,047, `tool-call.tsx` ~865, `opencode-data.ts` ~865, `api-types.ts` ~784, `session-events.ts` ~606, `recurring-day-fixture.ts` ~535.
233 lines
14 KiB
JavaScript
233 lines
14 KiB
JavaScript
// Explicit isolated CLI only: no service discovery, real provider, or user database.
|
|
import assert from "node:assert/strict"
|
|
import { spawn, execFileSync } from "node:child_process"
|
|
import { createServer } from "node:http"
|
|
import { watch } from "node:fs"
|
|
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"
|
|
import os from "node:os"
|
|
import path from "node:path"
|
|
import { setTimeout as delay } from "node:timers/promises"
|
|
import { tsImport } from "tsx/esm/api"
|
|
import Fastify from "fastify"
|
|
|
|
const cli = process.argv[2]
|
|
if (!cli || !path.isAbsolute(cli)) throw new Error("Pass an absolute CLI executable path for isolated testing")
|
|
const version = execFileSync(cli, ["--version"], { encoding: "utf8" }).trim().replace(/^opencode2? v/, "")
|
|
const temporary = path.join(os.tmpdir(), "opencode")
|
|
await mkdir(temporary, { recursive: true })
|
|
const root = await realpath(await mkdtemp(path.join(temporary, "codenomad-automation-native-")))
|
|
const project = path.join(root, "arbitrary-project")
|
|
await mkdir(project)
|
|
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(OPENCODE_|XDG_|CODENOMAD_)/.test(key)))
|
|
for (const key of ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
|
|
Object.assign(env, {
|
|
USERPROFILE: root, HOME: root, OPENCODE_TEST_HOME: root, LOCALAPPDATA: root, XDG_RUNTIME_DIR: root,
|
|
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "fixture.db"),
|
|
OPENCODE_SERVER_PASSWORD: "isolated-automation-fixture", OPENCODE_CONFIG_PROJECT_DISABLE: "1",
|
|
OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
|
|
})
|
|
delete env.WSL_DISTRO_NAME
|
|
await mkdir(env.OPENCODE_CONFIG_DIR)
|
|
const { DesktopPluginLifecycle, prepareDesktopPluginPresence } = await tsImport("../packages/server/src/opencode/desktop-plugin-lifecycle.ts", import.meta.url)
|
|
const { resolveDesktopPluginPaths } = await tsImport("../packages/server/src/opencode/desktop-plugin-paths.ts", import.meta.url)
|
|
const { createAutomationBridgeRegistration, publishAutomationBridge } = await tsImport("../packages/server/src/opencode/automation-plugin.ts", import.meta.url)
|
|
const { registerAutomationPluginRoute } = await tsImport("../packages/server/src/server/routes/automation-plugin.ts", import.meta.url)
|
|
const oldEnv = Object.fromEntries(["LOCALAPPDATA", "XDG_RUNTIME_DIR", "WSL_DISTRO_NAME",
|
|
"OPENCODE_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_DATA_HOME"].map(key => [key, process.env[key]]))
|
|
process.env.LOCALAPPDATA = root
|
|
process.env.XDG_RUNTIME_DIR = root
|
|
delete process.env.WSL_DISTRO_NAME
|
|
// The existing daemon has a different environment from this new backend and
|
|
// any proposed future-start overrides. Only its authenticated config API counts.
|
|
process.env.OPENCODE_CONFIG_DIR = path.join(root, "next-start-config")
|
|
process.env.XDG_CONFIG_HOME = path.join(root, "next-start-xdg-config")
|
|
process.env.XDG_DATA_HOME = path.join(root, "next-start-data")
|
|
const configFile = path.join(env.OPENCODE_CONFIG_DIR, "opencode.json")
|
|
await writeFile(configFile, "{}\n")
|
|
|
|
let client, developer = false, callBrowser = false, providerError
|
|
let observedTools = []
|
|
const nativeCalls = []
|
|
const bridge = Fastify()
|
|
let registration = createAutomationBridgeRegistration("http://127.0.0.1")
|
|
registerAutomationPluginRoute(bridge, {
|
|
bridgeToken: registration.token,
|
|
authManager: { isLoopbackRequest: request => request.ip === "127.0.0.1" },
|
|
workspaceManager: {
|
|
getSharedServiceClient: async () => client,
|
|
list: () => [{ id: "unrelated" }, { id: "fixture" }],
|
|
ownsLocation: async (id, location) => {
|
|
if (id === "unrelated") throw new Error("Synthetic unavailable unrelated native inventory")
|
|
return path.resolve(location.directory).toLowerCase() === project.toLowerCase()
|
|
},
|
|
},
|
|
nativeParent: {
|
|
available: true,
|
|
request: async (method, params) => {
|
|
if (method === "developer.status") return { status: developer
|
|
? { state: "ready", cdpUrl: "http://127.0.0.1:1", runId: "fixture", nativeIdentity: "fixture", windowId: "fixture" }
|
|
: { state: "stopped" } }
|
|
nativeCalls.push({ method, params })
|
|
return method === "browser.probe" ? { available: true } : { text: "NATIVE_BROWSER_FIXTURE" }
|
|
},
|
|
},
|
|
})
|
|
const provider = createServer(async (request, response) => {
|
|
try {
|
|
let raw = ""
|
|
for await (const chunk of request) raw += chunk
|
|
const body = JSON.parse(raw)
|
|
const primary = request.headers["x-automation-kind"] === "primary"
|
|
if (primary) observedTools = (body.tools ?? []).map(tool => tool.function?.name).filter(Boolean)
|
|
const invoke = primary && callBrowser
|
|
if (invoke) {
|
|
assert(observedTools.includes("codenomad_browser"), "Native model catalog must contain the browser tool")
|
|
callBrowser = false
|
|
}
|
|
if (!body.stream) {
|
|
response.setHeader("content-type", "application/json")
|
|
response.end(JSON.stringify({ id: "fixture", choices: [{ message: { role: "assistant", content: "Fixture" }, finish_reason: "stop" }] }))
|
|
return
|
|
}
|
|
response.setHeader("content-type", "text/event-stream")
|
|
const chunk = (delta, finish_reason = null) => response.write(`data: ${JSON.stringify({
|
|
id: "fixture", object: "chat.completion.chunk", model: "fixture", choices: [{ index: 0, delta, finish_reason }],
|
|
})}\n\n`)
|
|
chunk({ role: "assistant" })
|
|
if (invoke) chunk({ tool_calls: [{ index: 0, id: `fixture-${Date.now()}`, type: "function", function: {
|
|
name: "codenomad_browser", arguments: JSON.stringify({ action: "open", url: "https://example.com" }),
|
|
} }] })
|
|
else chunk({ content: "Done" })
|
|
chunk({}, invoke ? "tool_calls" : "stop")
|
|
response.end("data: [DONE]\n\n")
|
|
} catch (error) { providerError = error; response.destroy(error) }
|
|
})
|
|
let child, stopped, first, second, removeBridge, output = ""
|
|
async function until(predicate) {
|
|
for (let i = 0; i < 600; i++) {
|
|
if (providerError) throw providerError
|
|
if (await predicate()) return
|
|
if (child?.exitCode !== null && child?.exitCode !== undefined) throw new Error(`Isolated CLI exited: ${output}`)
|
|
await delay(100)
|
|
}
|
|
throw new Error(`Isolated condition timed out: ${output.slice(-6000)}`)
|
|
}
|
|
try {
|
|
await bridge.listen({ host: "127.0.0.1", port: 0 })
|
|
registration = { ...registration, url: `http://127.0.0.1:${bridge.server.address().port}/api/opencode-plugin/automation` }
|
|
removeBridge = await publishAutomationBridge(registration)
|
|
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
|
|
await mkdir(path.join(root, "fixture-plugin"))
|
|
const setupLog = path.join(root, "plugin-setup.log")
|
|
await writeFile(path.join(root, "fixture-plugin", "index.ts"), `import { appendFile } from 'node:fs/promises'
|
|
export default { id: 'automation-fixture', async setup(ctx) {
|
|
await appendFile(${JSON.stringify(setupLog)}, 'setup\\n')
|
|
await ctx.session.hook('http.request', event => event.request.headers.set('x-automation-kind', event.kind))
|
|
} }`)
|
|
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
|
model: "fixture/fixture", permissions: [{ action: "*", resource: "*", effect: "allow" }],
|
|
providers: { fixture: { package: "@opencode/ai/providers/openai-compatible",
|
|
settings: { baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture" }, models: { fixture: {} } } },
|
|
plugins: [path.join(root, "fixture-plugin")],
|
|
})
|
|
child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { cwd: root, env, windowsHide: true })
|
|
stopped = new Promise(resolve => child.once("close", resolve))
|
|
child.stdout.on("data", data => { output += data })
|
|
child.stderr.on("data", data => { output += data })
|
|
await until(() => /http:\/\/127\.0\.0\.1:\d+/.test(output))
|
|
const baseUrl = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0]
|
|
// Reuse production contract negotiation without ever performing service discovery.
|
|
const { OpenCodeCliService } = await tsImport("../packages/server/src/workspaces/opencode-cli-service.ts", import.meta.url)
|
|
const { OpenCodeSharedService } = await tsImport("../packages/server/src/workspaces/opencode-service.ts", import.meta.url)
|
|
const lifecycle = new OpenCodeCliService({ label: "Isolated automation", timeoutMs: 5000,
|
|
command: args => ({ command: cli, args, options: {} }),
|
|
}, { execFile: async (_file, args) => ({ stdout: args.join(" ") === "service get password" ? env.OPENCODE_SERVER_PASSWORD : baseUrl, stderr: "" }) })
|
|
const shared = new OpenCodeSharedService()
|
|
client = await shared.client({ kind: "lifecycle", identity: "isolated-automation", lifecycle })
|
|
const openPresence = async () => {
|
|
const backend = new OpenCodeSharedService()
|
|
const automation = new DesktopPluginLifecycle("automation")
|
|
const pruning = new DesktopPluginLifecycle("session-pruning")
|
|
try {
|
|
await backend.client({ kind: "lifecycle", identity: "isolated-backend", lifecycle,
|
|
prepareDesktopPlugins: async connection => {
|
|
const paths = await resolveDesktopPluginPaths(connection, { kind: "host", platform: process.platform, binary: cli })
|
|
assert.equal(paths.config, env.OPENCODE_CONFIG_DIR)
|
|
await prepareDesktopPluginPresence(paths, connection.assertCurrent, { pruning, automation })
|
|
return true
|
|
},
|
|
})
|
|
return async () => { await automation.stop(); await pruning.stop(); await backend.shutdown() }
|
|
} catch (error) {
|
|
await automation.stop(); await pruning.stop(); await backend.shutdown()
|
|
throw error
|
|
}
|
|
}
|
|
const location = { directory: project }
|
|
const session = await client.session.create({ location })
|
|
const sample = async () => {
|
|
await client.session.prompt({ sessionID: session.id, text: "Conclude briefly." })
|
|
await client.session.wait({ sessionID: session.id }, { signal: AbortSignal.timeout(20_000) })
|
|
if (providerError) throw providerError
|
|
return observedTools.filter(name => name.startsWith("codenomad_")).sort()
|
|
}
|
|
assert.deepEqual(await sample(), [], "No automation definitions before backend provisioning")
|
|
first = await openPresence()
|
|
assert.equal(await readFile(configFile, "utf8"), "{}\n", "Provisioning preserves the daemon's user configuration")
|
|
for (const directory of [process.env.OPENCODE_CONFIG_DIR, process.env.XDG_CONFIG_HOME, process.env.XDG_DATA_HOME]) {
|
|
await assert.rejects(readFile(path.join(directory, "plugins", "codenomad-automation.ts")), { code: "ENOENT" })
|
|
}
|
|
await until(async () => (await client.plugin.list({ location })).data.some(plugin => plugin.id === "codenomad.automation" && plugin.state.status === "active"))
|
|
await until(async () => (await client.plugin.list({ location })).data.some(plugin => plugin.id === "codenomad-session-pruning" && plugin.state.status === "active"))
|
|
await until(async () => (await sample()).includes("codenomad_browser"))
|
|
const allTools = ["codenomad_act", "codenomad_browser", "codenomad_inspect", "codenomad_screenshot"]
|
|
assert.deepEqual(await sample(), allTools, "Backend presence exposes all tools independently of Developer Mode")
|
|
// Keep project discovery disabled to exclude real ancestor configuration.
|
|
// That also disables OpenCode's config watcher, so explicitly observe the
|
|
// same directory with an OS watcher rather than accept a false-positive test.
|
|
await delay(2_500)
|
|
const setupBeforeHeartbeat = await readFile(setupLog, "utf8")
|
|
const configChanges = []
|
|
const configWatcher = watch(env.OPENCODE_CONFIG_DIR, { recursive: true }, (event, filename) => configChanges.push({ event, filename }))
|
|
try {
|
|
await delay(5_000)
|
|
assert.deepEqual(configChanges, [], "Backend heartbeats must not modify the watched configuration tree")
|
|
} finally {
|
|
configWatcher.close()
|
|
}
|
|
assert.equal(await readFile(setupLog, "utf8"), setupBeforeHeartbeat, "Backend heartbeats must not reload native configuration/plugins")
|
|
const skills = await client.skill.list({ location })
|
|
assert(JSON.stringify(skills).includes("codenomad-browser"), "Bundled skill is visible in an arbitrary project")
|
|
callBrowser = true
|
|
await sample()
|
|
assert.equal(nativeCalls.length, 1)
|
|
assert.equal(nativeCalls[0].method, "browser.execute")
|
|
assert.equal(nativeCalls[0].params.sessionID, session.id)
|
|
const denied = await bridge.inject({ method: "POST", url: "/api/opencode-plugin/automation",
|
|
headers: { "x-codenomad-automation-token": registration.token },
|
|
payload: { mode: "developer-execute", sessionID: session.id, command: { action: "inspect" } },
|
|
})
|
|
assert.equal(denied.statusCode, 404)
|
|
second = await openPresence()
|
|
await first(); first = undefined
|
|
await delay(2_100)
|
|
assert.deepEqual(await sample(), allTools)
|
|
await second(); second = undefined
|
|
await until(async () => (await sample()).length === 0)
|
|
assert(!JSON.stringify(await client.skill.list({ location })).includes("codenomad-browser"))
|
|
first = await openPresence()
|
|
await until(async () => (await sample()).length === 4)
|
|
assert.deepEqual(await sample(), allTools)
|
|
console.log(`PASS ${version}: connected-daemon discovery despite backend environment mismatch, automation and pruning provisioning, stable native plugins across heartbeats, late native discovery, arbitrary project, all definitions without Developer Mode, browser execution, native target fences, independent leases, clean shutdown and reopening`)
|
|
} finally {
|
|
await first?.(); await second?.(); await removeBridge?.()
|
|
child?.kill()
|
|
if (stopped) await stopped
|
|
await bridge.close()
|
|
await new Promise(resolve => provider.close(() => resolve()))
|
|
for (const [key, value] of Object.entries(oldEnv)) {
|
|
if (value === undefined) delete process.env[key]
|
|
else process.env[key] = value
|
|
}
|
|
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
|
}
|