mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-21 05:06:37 +00:00
## Summary Follow-up to #750: Git is a prerequisite for full CodeNomad functionality, but its absence must not prevent users from consulting their agent and asking it to help with installation. - Keep workspace startup and ordinary conversations available without host Git. Only the explicitly opened physical directory authorizes sessions in this mode; do not infer sibling worktrees from native project IDs. - Preserve Git/worktree operation requirements and return an actionable `git_required` failure for those operations. - Inform the agent via the owned native `codenomad.git-availability` instruction before prompts/custom commands. Include the backend platform and distinguish the backend account from WSL/remote agent execution. Remove the entry once Git is available, while preserving other instruction keys. There is no Git setup UI or installation gate. - Keep deletion admission safe across Git availability transitions by draining/blocking overlapping ancestor and descendant directory identities. - Narrow initial project-wide session lists to the opened directory in degraded mode; reject existing project-scoped cursors rather than reinterpreting their native pagination scope. - Teach the UI session inventory to request exact-directory pagination for degraded roots while retaining unknown repository membership. Owned children with inaccessible parents remain local display roots, preserving their native parent metadata; worktree selection is unavailable until Git recovers. - Bound advisory context synchronization independently from fail-closed profile-environment synchronization. An advisory timeout does not replay or prevent the original send; disconnect and runtime-generation fencing still apply. ## Validation - Targeted server regressions cover real workspace-manager/proxy admission without Git, foreign/child/alias directories, initial lists and native cursor scope, worktree operations, deletion fencing, environment admission and advisory timeout. - Existing workspace lifecycle, inventory, native Git-worktree identity, directory-authorization and worktree-deletion route suites pass. - Real isolated OpenCode **2.0.7 and 2.0.15** fixtures: Git absent from both backend and daemon PATH; project open, session creation, instruction synchronization, prompt admission, model request containing degraded context, assistant reply, and removal of stale instruction after backend PATH recovery. Private databases and a local synthetic provider only. - Server/UI TypeScript checks and production server/UI/plugin builds pass. Generated-client prefix regression also passes (bounded Node test runner for its retained UI handles). - Added no-Git unit/native qualification to the existing Linux/Windows/macOS compatibility workflow. Local native runs were on Windows; cross-platform CI outcomes are reported separately. - Final UI coverage: 88 store/tree/startup/authority tests, 13 worktree-browser tests, and 43 transcript/session-rendering browser tests pass. The earlier #751 instruction-proxy correction remains necessary and is covered by its existing regression tests. This change addresses the separate missing-Git admission failure.
126 lines
8.2 KiB
JavaScript
126 lines
8.2 KiB
JavaScript
// Explicit isolated CLI, private database and local provider: never shared services.
|
|
import assert from "node:assert/strict"
|
|
import { spawn, execFileSync } from "node:child_process"
|
|
import { randomUUID } from "node:crypto"
|
|
import { mkdtemp, mkdir, writeFile } from "node:fs/promises"
|
|
import { createServer } from "node:http"
|
|
import os from "node:os"
|
|
import path from "node:path"
|
|
import { setTimeout as delay } from "node:timers/promises"
|
|
import Fastify from "fastify"
|
|
import pino from "pino"
|
|
import { OpenCode } from "@opencode/client"
|
|
import { tsImport } from "tsx/esm/api"
|
|
import { stopFixtureChild } from "./native-fixture-guards.mjs"
|
|
|
|
const cli = process.argv[2]
|
|
assert(cli && path.isAbsolute(cli), "Pass an absolute isolated CLI executable path")
|
|
const parent = path.join(os.tmpdir(), "opencode")
|
|
await mkdir(parent, { recursive: true })
|
|
const root = await mkdtemp(path.join(parent, "codenomad-git-degraded-"))
|
|
const project = path.join(root, "project"), emptyPath = path.join(root, "empty-path")
|
|
await mkdir(project)
|
|
await mkdir(emptyPath)
|
|
execFileSync("git", ["init", project], { stdio: "ignore" })
|
|
const original = { ...process.env }
|
|
const env = Object.fromEntries(Object.entries(original).filter(([key]) => !/^(OPENCODE_|XDG_|CODENOMAD_)/i.test(key) && key.toLowerCase() !== "path"))
|
|
for (const key of ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"]) env[key] = path.join(root, key)
|
|
const password = randomUUID()
|
|
Object.assign(env, { HOME: root, USERPROFILE: root, OPENCODE_TEST_HOME: root, PATH: emptyPath,
|
|
OPENCODE_CONFIG_DIR: path.join(root, "config"), OPENCODE_DB: path.join(root, "fixture.db"),
|
|
OPENCODE_CONFIG_PROJECT_DISABLE: "1", OPENCODE_DISABLE_MODELS_FETCH: "1", OPENCODE_DISABLE_FFF: "1",
|
|
OPENCODE_SERVER_PASSWORD: password })
|
|
await mkdir(env.OPENCODE_CONFIG_DIR)
|
|
const requests = []
|
|
let providerError
|
|
const provider = createServer(async (req, res) => {
|
|
try {
|
|
let raw = ""
|
|
for await (const chunk of req) raw += chunk
|
|
const body = JSON.parse(raw)
|
|
requests.push(body)
|
|
if (!body.stream) {
|
|
res.setHeader("Content-Type", "application/json")
|
|
return res.end(JSON.stringify({ id: "fixture", choices: [{ message: { role: "assistant", content: "Git installation help" }, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }))
|
|
}
|
|
res.setHeader("Content-Type", "text/event-stream")
|
|
const chunk = (delta, finish_reason = null) => res.write("data: " + JSON.stringify({
|
|
id: "fixture", object: "chat.completion.chunk", model: "fixture", choices: [{ index: 0, delta, finish_reason }],
|
|
...(finish_reason ? { usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } } : {}),
|
|
}) + "\n\n")
|
|
chunk({ role: "assistant", content: "Git installation help" }); chunk({}, "stop"); res.end("data: [DONE]\n\n")
|
|
} catch (error) { providerError = error; res.destroy(error) }
|
|
})
|
|
await new Promise(resolve => provider.listen(0, "127.0.0.1", resolve))
|
|
env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ model: "fixture/fixture", providers: { fixture: {
|
|
package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: `http://127.0.0.1:${provider.address().port}/v1`, apiKey: "fixture-only" }, models: { fixture: {} },
|
|
} } })
|
|
let output = "", spawnError, app, manager
|
|
const child = spawn(cli, ["serve", "--hostname", "127.0.0.1", "--port", "0", "--print-logs"], { cwd: root, env, windowsHide: true })
|
|
const stopped = new Promise(resolve => child.once("close", resolve))
|
|
child.on("error", error => { spawnError = error })
|
|
child.stdout.on("data", data => { output += data })
|
|
child.stderr.on("data", data => { output += data })
|
|
try {
|
|
const deadline = Date.now() + 30_000
|
|
while (!/http:\/\/127\.0\.0\.1:\d+/.test(output)) {
|
|
if (spawnError) throw spawnError
|
|
if (child.exitCode !== null || Date.now() > deadline) throw new Error(output)
|
|
await delay(25)
|
|
}
|
|
const url = output.match(/http:\/\/127\.0\.0\.1:\d+/)[0]
|
|
const headers = { authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` }
|
|
const endpoint = { url, auth: { type: "basic", username: "opencode", password } }
|
|
const boundedFetch = (url, init) => fetch(url, { ...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(30_000)]) })
|
|
const native = OpenCode.make({ baseUrl: url, headers, fetch: boundedFetch })
|
|
const info = await native.server.info()
|
|
const { rememberRuntime } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url)
|
|
rememberRuntime(endpoint, { version: info.version, pid: info.pid, discovery: "info" })
|
|
for (const key of Object.keys(process.env)) delete process.env[key]
|
|
Object.assign(process.env, env)
|
|
const { WorkspaceManager } = await tsImport("../packages/server/src/workspaces/manager.ts", import.meta.url)
|
|
const { EventBus } = await tsImport("../packages/server/src/events/bus.ts", import.meta.url)
|
|
const { registerInstanceProxyRoutes } = await tsImport("../packages/server/src/server/http-server.ts", import.meta.url)
|
|
const { WorktreeDeletionFence } = await tsImport("../packages/server/src/workspaces/worktree-session-evacuation.ts", import.meta.url)
|
|
const logger = pino({ level: "silent" })
|
|
manager = new WorkspaceManager({ rootDir: root, settings: { getOwner: () => ({ environmentVariables: {} }) },
|
|
binaryResolver: { resolveDefault: () => ({ path: cli, label: "isolated" }) }, eventBus: new EventBus(), logger,
|
|
hostServiceLifecycleFactory: () => ({ discover: async () => endpoint, ensure: async () => endpoint }),
|
|
})
|
|
const { workspace } = await manager.create(project)
|
|
assert.equal((await manager.getWorktrees(workspace.id, "fresh")).gitAvailable, false)
|
|
app = Fastify()
|
|
registerInstanceProxyRoutes(app, { workspaceManager: manager, logger, worktreeDeletionFence: new WorktreeDeletionFence() })
|
|
await app.listen({ host: "127.0.0.1", port: 0 })
|
|
const proxy = OpenCode.make({ baseUrl: `http://127.0.0.1:${app.server.address().port}/workspaces/${workspace.id}/instance/`, fetch: boundedFetch })
|
|
const session = await proxy.session.create({ location: { directory: project } })
|
|
await proxy.session.instructions.entry.remove({ sessionID: session.id, key: "codenomad.voice-mode" })
|
|
await proxy.session.instructions.entry.put({ sessionID: session.id, key: "codenomad.session-placement", value: "Keep the chosen directory." })
|
|
await proxy.session.prompt({ sessionID: session.id, text: "Help me install Git for this machine." })
|
|
await proxy.session.wait({ sessionID: session.id }, { signal: AbortSignal.timeout(30_000) })
|
|
if (providerError) throw providerError
|
|
assert.ok(requests.some(body => body.stream && JSON.stringify(body.messages).includes("CodeNomad cannot run Git")), "the real model request must include degraded context")
|
|
assert.match(JSON.stringify(await proxy.message.list({ sessionID: session.id, limit: 100 })), /Git installation help/)
|
|
assert.ok((await native.session.instructions.entry.list({ sessionID: session.id })).some(entry => entry.key === "codenomad.git-availability"))
|
|
// Recover backend PATH only; no shared/native daemon restart is needed for the check.
|
|
delete process.env.PATH
|
|
for (const [key, value] of Object.entries(original)) if (key.toLowerCase() === "path") process.env[key] = value
|
|
await proxy.session.prompt({ sessionID: session.id, text: "Continue the conversation." })
|
|
await proxy.session.wait({ sessionID: session.id }, { signal: AbortSignal.timeout(30_000) })
|
|
const entries = await native.session.instructions.entry.list({ sessionID: session.id })
|
|
assert.ok(!entries.some(entry => entry.key === "codenomad.git-availability"))
|
|
assert.ok(entries.some(entry => entry.key === "codenomad.session-placement"))
|
|
assert.equal((await native.server.info()).pid, info.pid)
|
|
console.log(`PASS OpenCode ${info.version}: no Git in backend or daemon PATH; workspace + instructions + prompt + model context + answer; context removed after backend PATH recovery`)
|
|
} finally {
|
|
await app?.close()
|
|
await manager?.shutdown()
|
|
for (const key of Object.keys(process.env)) delete process.env[key]
|
|
Object.assign(process.env, original)
|
|
try { await stopFixtureChild(child, stopped) } finally {
|
|
provider.closeAllConnections()
|
|
await new Promise(resolve => provider.close(resolve))
|
|
await writeFile(path.join(root, "daemon.log"), output)
|
|
console.log(`Isolated fixture: ${root}`)
|
|
}
|
|
}
|